Cookie Encryption Middleware#
Comprehensive guide to reading, setting, and encrypting cookies in Webrick. Covers both basic cookie operations and transparent encryption for sensitive data.
Table of Contents#
Basic Cookie Operations#
Reading Cookies (Request)#
use Infocyph\Webrick\Request\Request;
use Infocyph\Webrick\Response\Response;
Route::get('/cookie/read', function (Request $r) {
return Response::json([
'all' => $r->getCookieParams(), // All cookies
'demo' => $r->cookie('demo'), // Single cookie (null if missing)
]);
});
If CookieEncryptionMiddleware is enabled, cookie() returns the decrypted value automatically.
Setting Cookies (Response)#
Set cookies explicitly via Set-Cookie header:
Route::get('/cookie/set', function () {
$cookie = rawurlencode('demo') . '=' . rawurlencode('secret-value')
. '; Path=/; HttpOnly; SameSite=Lax; Secure';
return Response::json(['ok'=>true])
->withAddedHeader('Set-Cookie', $cookie);
});
Cookie Attributes:
Path=/— Cookie visible to entire siteDomain=example.com— Cross-subdomain sharingExpires=<date>/Max-Age=<seconds>— PersistenceHttpOnly— Hide from JavaScript (XSS protection)Secure— HTTPS onlySameSite=Lax|Strict|None— CSRF boundary
Deleting Cookies#
Overwrite with expired cookie:
Route::get('/cookie/clear', function () {
$expired = 'demo=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax';
return Response::json(['ok'=>true])
->withAddedHeader('Set-Cookie', $expired);
});
Cookie Encryption Middleware#
Keep sensitive cookie values private by encrypting them transparently. The middleware decrypts on request and allows you to set plain values on response.
Why Encrypt Cookies?#
Without Encryption:
❌ Values visible in browser DevTools
❌ Can be tampered with by users
❌ Risk of session hijacking if tokens leaked
With Encryption:
✅ Values unreadable to end users
✅ Tampering detected (AEAD encryption)
✅ Automatic decryption in handlers
✅ Optional server-side storage for large values
Configuration#
Wiring#
Place it in pre-global (before anything reads cookies):
use Infocyph\Webrick\Middleware\CookieEncryptionMiddleware;
use Psr\Cache\CacheItemPoolInterface;
$preGlobal[] = new CookieEncryptionMiddleware(
keyOrKeys: $_ENV['WEBRICK_COOKIE_KEY'] ?? 'change-me-32-bytes-minimum',
cookiePrefix: 'enc_', // Only encrypt cookies with this prefix
maxBytes: 3_800, // Per-cookie size limit (4KB browser limit)
store: $cachePool, // PSR-6 cache for server-side storage fallback
storeTtl: 86_400, // Server-side storage TTL (24 hours)
dropOnDecryptFailure: true, // Fail-closed: drop invalid cookies
forceSecure: true, // Enforce Secure attribute
forceHttpOnly: true, // Enforce HttpOnly attribute
defaultSameSite: 'Lax' // Default SameSite policy
);
Constructor Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
required |
32+ byte key(s) for AES-256-GCM encryption |
|
|
|
Only cookies starting with this prefix are encrypted |
|
|
|
Max cookie size before using server-side storage |
|
|
|
PSR-6 cache for storing large cookie values server-side |
|
|
|
TTL for server-side stored values (seconds) |
|
|
|
Drop invalid cookies (true) or leave raw value (false) |
|
|
|
Enforce |
|
|
|
Enforce |
|
|
|
Default |
Recommended Middleware Order#
$preGlobal = [
// Early guards
\Infocyph\Webrick\Middleware\GatewayHardeningMiddleware::class,
\Infocyph\Webrick\Middleware\TelemetryMiddleware::class,
\Infocyph\Webrick\Middleware\MaintenanceModeMiddleware::class,
\Infocyph\Webrick\Middleware\RequestLimitsMiddleware::class,
\Infocyph\Webrick\Middleware\ThrottleMiddleware::class,
// Cookie decryption (before handlers read cookies)
new CookieEncryptionMiddleware(
keyOrKeys: $_ENV['WEBRICK_COOKIE_KEY'],
cookiePrefix: 'enc_'
),
// Request normalization
\Infocyph\Webrick\Middleware\NormalizeMethodMiddleware::class,
\Infocyph\Webrick\Middleware\InputSanitizerMiddleware::class,
// ...
];
Reading Cookies#
Basic Reading#
Route::get('/profile', function (Request $r) {
// If cookie was encrypted, this is automatically decrypted
$sessionId = $r->cookie('enc_session');
// Regular (non-encrypted) cookie
$theme = $r->cookie('theme');
return Response::json([
'session' => $sessionId, // Decrypted value
'theme' => $theme
]);
});
Reading All Cookies#
$allCookies = $r->getCookieParams();
// Encrypted cookies are automatically decrypted
Setting Cookies#
Encrypted Cookies (Set Plain, Stored Encrypted)#
Route::post('/login', function (Request $r) {
$user = authenticate($r->input('email'), $r->input('password'));
// Set cookie with encryption prefix
// Middleware will encrypt automatically on next request
$cookie = 'enc_session=' . rawurlencode($user->sessionToken)
. '; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=3600';
return Response::json(['user' => $user])
->withAddedHeader('Set-Cookie', $cookie);
});
On next request: Browser sends enc_session=<encrypted>, middleware decrypts to original value.
Regular (Non-Encrypted) Cookies#
// Without 'enc_' prefix, cookie is NOT encrypted
$cookie = 'theme=dark; Path=/; Max-Age=31536000';
return Response::json(['ok' => true])
->withAddedHeader('Set-Cookie', $cookie);
Cookie Attributes#
Path Scoping#
// Scoped to /admin only
$cookie = 'enc_admin_token=' . $token . '; Path=/admin; HttpOnly; Secure; SameSite=Strict';
// Available site-wide
$cookie = 'enc_session=' . $token . '; Path=/; HttpOnly; Secure; SameSite=Lax';
Domain Scoping (Cross-Subdomain)#
// Share across subdomains
$cookie = 'enc_shared=' . $value
. '; Path=/; Domain=.example.com; HttpOnly; Secure; SameSite=Lax';
⚠️ Security Risk: All subdomains can read/write this cookie.
SameSite Options#
Value |
Behavior |
Use Case |
|---|---|---|
|
Never sent on cross-site requests |
High-security (banking) |
|
Sent on top-level navigations (GET only) |
Most web apps (default) |
|
Always sent (requires |
Embedded widgets, OAuth |
// Strict (most secure)
$cookie = 'enc_csrf=' . $token . '; HttpOnly; Secure; SameSite=Strict';
// Lax (standard)
$cookie = 'enc_session=' . $session . '; HttpOnly; Secure; SameSite=Lax';
// None (cross-origin embedding)
$cookie = 'widget_state=' . $state . '; Secure; SameSite=None'; // Must have Secure
Advanced Patterns#
Session Cookies with Rotation#
Route::post('/login', function (Request $r) {
$user = AuthService::attempt($r->input('email'), $r->input('password'));
if (!$user) {
return Response::json(['error' => 'Invalid credentials'], 401);
}
// Generate session ID
$sessionId = bin2hex(random_bytes(32));
// Store session server-side
Cache::put("session:{$sessionId}", [
'user_id' => $user->id,
'created_at' => time(),
'ip' => $r->getAttribute('client_ip'),
'user_agent' => $r->getHeaderLine('User-Agent')
], 3600);
// Set encrypted cookie
$cookie = 'enc_sess=' . $sessionId
. '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600';
return Response::json(['user' => $user])
->withAddedHeader('Set-Cookie', $cookie);
});
// Validate in middleware
final class SessionMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
$sessionId = $r->cookie('enc_sess'); // Auto-decrypted
if (!$sessionId) {
return Response::json(['error' => 'Not authenticated'], 401);
}
$session = Cache::get("session:{$sessionId}");
if (!$session) {
return Response::json(['error' => 'Session expired'], 401);
}
// Refresh TTL on activity
Cache::put("session:{$sessionId}", $session, 3600);
// Attach user to request
$r = $r->withAttribute('auth.user_id', $session['user_id']);
return $next($r);
}
}
Remember Me (Long-Lived Token)#
Route::post('/login', function (Request $r) {
$user = AuthService::attempt($r->input('email'), $r->input('password'));
$cookies = [];
// Short-lived session
$sessionId = bin2hex(random_bytes(32));
Cache::put("session:{$sessionId}", ['user_id' => $user->id], 3600);
$cookies[] = 'enc_sess=' . $sessionId
. '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600';
// Long-lived remember token (if requested)
if ($r->input('remember')) {
$rememberToken = bin2hex(random_bytes(32));
// Store hashed token in database
DB::insert('remember_tokens', [
'user_id' => $user->id,
'token_hash' => hash('sha256', $rememberToken),
'expires_at' => date('Y-m-d H:i:s', time() + 2592000) // 30 days
]);
$cookies[] = 'enc_remember=' . $rememberToken
. '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000';
}
$response = Response::json(['user' => $user]);
foreach ($cookies as $cookie) {
$response = $response->withAddedHeader('Set-Cookie', $cookie);
}
return $response;
});
Cookie Prefixes for Security#
Use __Host- and __Secure- prefixes:
// __Host- prefix (most secure)
// - Must have Secure flag
// - Must have Path=/
// - Must NOT have Domain
$cookie = '__Host-enc_sess=' . $sessionId
. '; Path=/; Secure; HttpOnly; SameSite=Strict';
// __Secure- prefix (secure only)
// - Must have Secure flag
// - Can have Domain and Path
$cookie = '__Secure-enc_sess=' . $sessionId
. '; Path=/; Secure; HttpOnly; SameSite=Lax; Domain=.example.com';
Browser enforces these rules and won’t accept cookies that violate them.
Key Rotation (Dual-Key)#
Decrypt with multiple keys, encrypt with the first (current) key:
$currentKey = $_ENV['COOKIE_KEY_CURRENT']; // New key
$oldKey = $_ENV['COOKIE_KEY_OLD']; // Previous key (grace period)
new CookieEncryptionMiddleware(
keyOrKeys: [$currentKey, $oldKey], // Try decrypting with both
cookiePrefix: 'enc_'
// Always encrypts with $currentKey (first in array)
);
Rotation Process:
Add new key as first element
Keep old key for grace period (e.g., 30 days)
Remove old key after grace period
Users’ cookies auto-upgrade on next request
Server-Side Storage (Large Cookies)#
For cookies exceeding browser limits (~4KB):
use Symfony\Component\Cache\Adapter\RedisAdapter;
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$cachePool = new RedisAdapter($redis);
new CookieEncryptionMiddleware(
keyOrKeys: $_ENV['WEBRICK_COOKIE_KEY'],
cookiePrefix: 'enc_',
maxBytes: 3_800, // If cookie > 3.8KB...
store: $cachePool, // ...store value in Redis
storeTtl: 86_400 // ...with 24h TTL
);
How it works:
Middleware encrypts the value
If encrypted value >
maxBytes, stores in cache with random IDSets cookie:
enc_name=S:abc123(pointer to cache)On read, fetches from cache using ID
Security Best Practices#
1. Always Use HttpOnly for Session Cookies#
Prevents JavaScript access (mitigates XSS):
// ✅ Good
$cookie = 'enc_sess=' . $id . '; HttpOnly; Secure; SameSite=Strict';
// ❌ Bad (JS can read via document.cookie)
$cookie = 'enc_sess=' . $id . '; Secure; SameSite=Strict';
2. Always Use Secure in Production#
Forces HTTPS-only transmission:
// Conditional based on environment
$secure = $_ENV['APP_ENV'] === 'production' ? '; Secure' : '';
$cookie = 'enc_sess=' . $id . '; HttpOnly; SameSite=Lax' . $secure;
// Or use middleware with forceSecure: true
3. Use Strong Encryption Keys#
// ❌ Bad
$key = 'my-secret-key'; // Too short, predictable
// ✅ Good
$key = base64_decode($_ENV['WEBRICK_COOKIE_KEY']); // 32+ bytes, random
// Generate a key
$key = random_bytes(32);
echo base64_encode($key); // Store this in .env
4. Validate Decrypted Values#
// Don't trust cookie values blindly
$userId = $r->cookie('enc_user_id'); // ❌ Dangerous even if encrypted
// Verify against server-side session
$sessionId = $r->cookie('enc_sess');
$session = Cache::get("session:{$sessionId}");
if ($session && $session['user_id'] === $userId) {
// ✅ Safe
}
5. Rotate Session IDs#
After privilege escalation or sensitive operations:
Route::post('/change-password', function(Request $r) {
$oldSessionId = $r->cookie('enc_sess');
// Change password...
// Invalidate old session
Cache::forget("session:{$oldSessionId}");
// Create new session
$newSessionId = bin2hex(random_bytes(32));
Cache::put("session:{$newSessionId}", ['user_id' => $userId], 3600);
return Response::json(['success' => true])
->withAddedHeader('Set-Cookie',
'enc_sess=' . $newSessionId . '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
);
});
6. Limit Cookie Scope#
// ✅ Good: Scoped to /admin
$cookie = 'enc_admin_sess=' . $id . '; Path=/admin; HttpOnly; Secure; SameSite=Strict';
// ❌ Bad: Accessible everywhere
$cookie = 'enc_admin_sess=' . $id . '; Path=/; HttpOnly; Secure; SameSite=Strict';
7. Set Appropriate Expiration#
// Session cookie (expires when browser closes)
$cookie = 'enc_sess=' . $id . '; Path=/; HttpOnly; Secure; SameSite=Lax';
// Persistent cookie (30 days)
$cookie = 'enc_remember=' . $token . '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000';
// Short-lived (5 minutes)
$cookie = 'enc_otp_challenge=' . $challenge . '; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=300';
Testing#
Unit Test#
use PHPUnit\Framework\TestCase;
class CookieTest extends TestCase
{
public function testSetsCookieWithCorrectAttributes(): void
{
$response = Response::json(['ok' => true])
->withAddedHeader('Set-Cookie',
'enc_test=value; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
);
$setCookie = $response->getHeaderLine('Set-Cookie');
$this->assertStringContainsString('enc_test=value', $setCookie);
$this->assertStringContainsString('HttpOnly', $setCookie);
$this->assertStringContainsString('Secure', $setCookie);
$this->assertStringContainsString('SameSite=Lax', $setCookie);
}
public function testReadsCookieFromRequest(): void
{
$request = Request::fake(
headers: ['Cookie' => 'enc_sess=abc123; theme=dark'],
method: 'GET',
uri: '/',
);
$this->assertEquals('abc123', $request->cookie('enc_sess'));
$this->assertEquals('dark', $request->cookie('theme'));
$this->assertNull($request->cookie('nonexistent'));
}
}
Integration Test#
#!/bin/bash
# test-cookies.sh
set -e
BASE_URL="http://localhost:8000"
echo "Testing cookie functionality..."
# Test 1: Set cookie
RESPONSE=$(curl -s -c cookies.txt -b cookies.txt "$BASE_URL/login" \
-d "[email protected]&password=secret" \
-w "\n%{http_code}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ Cookie set on login"
else
echo "❌ Login failed (HTTP $HTTP_CODE)"
exit 1
fi
# Test 2: Cookie persists
RESPONSE=$(curl -s -b cookies.txt "$BASE_URL/profile" -w "\n%{http_code}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ Cookie persists across requests"
else
echo "❌ Cookie not sent or invalid (HTTP $HTTP_CODE)"
exit 1
fi
# Test 3: Cookie cleared on logout
curl -s -c cookies.txt -b cookies.txt "$BASE_URL/logout" > /dev/null
RESPONSE=$(curl -s -b cookies.txt "$BASE_URL/profile" -w "\n%{http_code}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
if [ "$HTTP_CODE" = "401" ]; then
echo "✅ Cookie cleared on logout"
else
echo "❌ Cookie still valid after logout (HTTP $HTTP_CODE)"
exit 1
fi
rm -f cookies.txt
echo "✅ All cookie tests passed"
Troubleshooting#
Issue: Cookie not decrypting#
Symptoms: Handler receives encrypted value instead of plain text
Causes:
Middleware not enabled
Wrong cookie prefix
Key mismatch
Debug:
// Check if middleware is active
$encrypted = $r->getHeaderLine('Cookie'); // Raw from browser
$decrypted = $r->cookie('enc_sess'); // After middleware
error_log("Raw: {$encrypted}");
error_log("Decrypted: {$decrypted}");
Fix:
// Ensure middleware is in pre-global
$preGlobal[] = new CookieEncryptionMiddleware(
keyOrKeys: $_ENV['WEBRICK_COOKIE_KEY'],
cookiePrefix: 'enc_' // Match your cookie names
);
Issue: Users logged out unexpectedly#
Symptoms: Sessions don’t persist after deploy
Causes:
Key changed between deployments
Cookie encryption enabled mid-flight
Fix: Use key rotation during transition:
new CookieEncryptionMiddleware(
keyOrKeys: [$newKey, $oldKey], // Support both
cookiePrefix: 'enc_'
);
Issue: Large cookie rejected#
Symptoms: Browser doesn’t store cookie
Cause: Cookie exceeds 4KB limit
Fix: Enable server-side storage:
new CookieEncryptionMiddleware(
keyOrKeys: $_ENV['WEBRICK_COOKIE_KEY'],
maxBytes: 3_800,
store: $cachePool // Store large values in cache
);
Issue: Cookie visible in DevTools#
Symptoms: Can see cookie value in browser
Cause: Cookie name doesn’t match prefix
Fix:
// ❌ Wrong: No 'enc_' prefix
$cookie = 'session=' . $id . '...';
// ✅ Correct: Has 'enc_' prefix
$cookie = 'enc_session=' . $id . '...';
Browser DevTools Inspection#
Chrome/Edge DevTools#
Open DevTools (F12)
Go to Application tab
Expand Cookies in left sidebar
Select your domain
What to Check:
✅
HttpOnlycolumn checked for session cookies✅
Securecolumn checked (in production)✅
SameSiteset appropriately✅
Expires / Max-Agematches expectation✅
Pathscoped correctly✅ Encrypted cookies show gibberish value (not plain text)
Summary#
Cookie encryption provides:
✅ Confidentiality (values unreadable)
✅ Integrity (tampering detected)
✅ Automatic decryption in handlers
✅ Optional server-side storage for large values
When to use:
✅ Session tokens
✅ Remember-me tokens
✅ User preferences with sensitive data
✅ CSRF tokens
When NOT to use:
❌ Public preferences (theme, language)
❌ Analytics/tracking cookies
❌ Small, non-sensitive values
Golden rule: Encrypt anything you wouldn’t want users to read or modify.