Cookies & Encryption#
Read and set cookies safely. Optionally enable transparent encryption so values at rest (client-side) are unreadable without your key.
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(),
'demo' => $r->cookie('demo'), // null if missing
]);
});
If CookieEncryptionMiddleware is enabled, cookie('name') returns the decrypted value.
Setting cookies (response)#
Set cookies explicitly via Set-Cookie header (clear & PSR friendly):
use Infocyph\Webrick\Response\Response;
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);
});
Attributes you’ll commonly use
Path=/– cookie visible to entire siteDomain=example.com– cross-subdomain sharing if neededExpires=Wed, 31 Dec 2030 23:59:59 GMT/Max-Age=3600– persistenceHttpOnly– hide from JS (mitigates XSS cookie theft)Secure– HTTPS onlySameSite=Lax|Strict|None– CSRF boundary (setNoneonly withSecure)
Encrypted cookies (optional)#
Enable the middleware once (pre-global) and pass a secret key:
$preGlobal[] = new \Infocyph\Webrick\Middleware\CookieEncryptionMiddleware(
$_ENV['WEBRICK_COOKIE_KEY'] ?? 'change-me'
);
Write cookies as usual (plain).
Read via
$r->cookie('name')→ decrypted value if that cookie was written encrypted.Rotation: deploy a new key and keep the previous key for a grace period if the middleware supports multi-key decryption; otherwise rotate during a maintenance window.
Use encrypted cookies for any value you don’t want end users to read or tamper with (session info, feature flags with risks). Pair with server-side verification when integrity matters.
Deleting cookies#
Overwrite with an expired cookie (and same scope):
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);
});
Scoped cookies (subdomains & paths)#
Path scoping:
Path=/adminlimits visibility to/admin/*.Domain scoping:
Domain=.example.comshares across subdomains (use carefully).For domain-scoped routes (e.g.,
api.example.com), set the cookie domain explicitly to match where the browser should send it.
Security notes#
Treat cookies as untrusted input on the server; validate and sanitize even when encrypted.
Don’t store PII or secrets client-side unless absolutely necessary and encrypted.
Use
HttpOnly+Secure+SameSiteappropriately (oftenLaxfor app cookies;Strictfor sensitive flows).For login/CSRF-sensitive POST routes, combine SameSite with CSRF tokens.
Examples#
Flash message via cookie#
// set
Route::post('/login', function () {
$c = 'flash=' . rawurlencode('Welcome back!')
. '; Path=/; Max-Age=10; HttpOnly; SameSite=Lax';
return Response::redirect('/', 303)->withAddedHeader('Set-Cookie', $c);
});
// read & clear
Route::get('/', function (Request $r) {
$msg = $r->cookie('flash');
$clear = 'flash=; Path=/; Max-Age=0';
return Response::json(['flash'=>$msg])->withAddedHeader('Set-Cookie', $clear);
});
Remember-me (hint)#
Use a signed, encrypted token (not just a user ID). Validate server-side and rotate on use.
Advanced Cookie Patterns#
Session Cookies with Rotation#
// Create session with automatic rotation
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[] = '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[] = '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;
});
// Auto-login from remember token
final class RememberMeMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
// Skip if already authenticated
if ($r->getAttribute('auth.user_id')) {
return $next($r);
}
$rememberToken = $r->cookie('remember');
if (!$rememberToken) {
return $next($r);
}
// Find token in database
$tokenHash = hash('sha256', $rememberToken);
$record = DB::queryOne(
'SELECT * FROM remember_tokens WHERE token_hash = ? AND expires_at > NOW()',
[$tokenHash]
);
if (!$record) {
// Invalid/expired - clear cookie
return $next($r)->withAddedHeader('Set-Cookie',
'remember=; Path=/; Max-Age=0'
);
}
// Create new session
$sessionId = bin2hex(random_bytes(32));
Cache::put("session:{$sessionId}", ['user_id' => $record['user_id']], 3600);
$r = $r->withAttribute('auth.user_id', $record['user_id']);
return $next($r)->withAddedHeader('Set-Cookie',
'sess=' . $sessionId . '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
);
}
}
Cookie Prefixes for Security#
Use __Host- and __Secure- prefixes for enhanced security:
// __Host- prefix (most secure)
// - Must have Secure flag
// - Must have Path=/
// - Must NOT have Domain
$cookie = '__Host-sess=' . $sessionId
. '; Path=/; Secure; HttpOnly; SameSite=Strict';
// __Secure- prefix (secure only)
// - Must have Secure flag
// - Can have Domain and Path
$cookie = '__Secure-sess=' . $sessionId
. '; Path=/; Secure; HttpOnly; SameSite=Lax; Domain=.example.com';
// Browser enforces these rules and won't accept cookies that violate them
Benefits:
Prevents subdomain attacks
Forces HTTPS-only cookies
Clear security intent
Cookie Chunking (Large Values)#
When cookie value exceeds browser limits (~4KB):
final class CookieChunker
{
private const CHUNK_SIZE = 3800; // Leave room for name + attributes
public static function set(Response $resp, string $name, string $value, string $attributes): Response
{
$chunks = str_split($value, self::CHUNK_SIZE);
foreach ($chunks as $index => $chunk) {
$chunkName = "{$name}_{$index}";
$cookie = $chunkName . '=' . rawurlencode($chunk) . '; ' . $attributes;
$resp = $resp->withAddedHeader('Set-Cookie', $cookie);
}
// Store chunk count
$cookie = "{$name}_count=" . count($chunks) . '; ' . $attributes;
return $resp->withAddedHeader('Set-Cookie', $cookie);
}
public static function get(Request $r, string $name): ?string
{
$count = (int)$r->cookie("{$name}_count");
if ($count === 0) {
return null;
}
$chunks = [];
for ($i = 0; $i < $count; $i++) {
$chunk = $r->cookie("{$name}_{$i}");
if ($chunk === null) {
return null; // Incomplete
}
$chunks[] = $chunk;
}
return implode('', $chunks);
}
public static function clear(Response $resp, string $name, int $maxChunks = 10): Response
{
for ($i = 0; $i < $maxChunks; $i++) {
$cookie = "{$name}_{$i}=; Path=/; Max-Age=0";
$resp = $resp->withAddedHeader('Set-Cookie', $cookie);
}
$cookie = "{$name}_count=; Path=/; Max-Age=0";
return $resp->withAddedHeader('Set-Cookie', $cookie);
}
}
// Usage
$response = CookieChunker::set(
$response,
'large_data',
$largeString,
'Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
);
$value = CookieChunker::get($request, 'large_data');
⚠️ Better Alternative: Store large data server-side and use a small cookie as a pointer.
Cross-Domain Cookies (Subdomains)#
Share cookies across subdomains:
// Set on example.com, accessible to app.example.com and api.example.com
$cookie = 'shared=' . $value
. '; Path=/; Domain=.example.com; Secure; HttpOnly; SameSite=Lax';
return $response->withAddedHeader('Set-Cookie', $cookie);
Security Considerations:
All subdomains can read/write the cookie
Compromise of any subdomain = compromise of cookie
Use only for non-sensitive data or with additional verification
Cookie Priorities (Chrome)#
Control eviction order when browser runs out of space:
// High priority (kept longest)
$cookie = 'important=' . $value . '; Path=/; Priority=High';
// Medium priority (default)
$cookie = 'normal=' . $value . '; Path=/; Priority=Medium';
// Low priority (evicted first)
$cookie = 'cache=' . $value . '; Path=/; Priority=Low';
Use Cases:
High: Authentication, CSRF tokens
Medium: User preferences
Low: Analytics, A/B test variants
Cookie Security Best Practices#
1. Always Use HttpOnly for Session Cookies#
Prevents JavaScript access (mitigates XSS):
// ✅ Good
$cookie = 'sess=' . $id . '; HttpOnly; Secure; SameSite=Strict';
// ❌ Bad (JS can read via document.cookie)
$cookie = '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 = 'sess=' . $id . '; HttpOnly; SameSite=Lax' . $secure;
// Or use CookieEncryptionMiddleware with forceSecure: true
3. Use SameSite Appropriately#
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)
Route::post('/transfer-funds', /* ... */, [
'middleware' => [function($r, $next) {
$cookie = 'csrf=' . bin2hex(random_bytes(16))
. '; HttpOnly; Secure; SameSite=Strict';
return $next($r)->withAddedHeader('Set-Cookie', $cookie);
}]
]);
// Lax (standard)
$cookie = 'sess=' . $id . '; HttpOnly; Secure; SameSite=Lax';
// None (cross-origin embedding)
$cookie = 'widget_state=' . $state . '; Secure; SameSite=None';
4. Limit Cookie Scope#
Use specific Path and avoid broad Domain:
// ✅ Good: Scoped to /admin
$cookie = 'admin_sess=' . $id . '; Path=/admin; HttpOnly; Secure; SameSite=Strict';
// ❌ Bad: Accessible everywhere
$cookie = 'admin_sess=' . $id . '; Path=/; HttpOnly; Secure; SameSite=Strict';
// ✅ Good: No Domain (current origin only)
$cookie = 'sess=' . $id . '; Path=/; HttpOnly; Secure; SameSite=Lax';
// ⚠️ Risky: Shared across all subdomains
$cookie = 'sess=' . $id . '; Path=/; Domain=.example.com; HttpOnly; Secure; SameSite=Lax';
5. Set Appropriate Expiration#
// Session cookie (expires when browser closes)
$cookie = 'sess=' . $id . '; Path=/; HttpOnly; Secure; SameSite=Lax';
// Persistent cookie (30 days)
$cookie = 'remember=' . $token . '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000';
// Short-lived (5 minutes)
$cookie = 'otp_challenge=' . $challenge . '; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=300';
6. Validate Cookie Integrity#
// Don't trust cookie values blindly
$userId = $r->cookie('user_id'); // ❌ Dangerous
// Verify against server-side session
$sessionId = $r->cookie('sess');
$session = Cache::get("session:{$sessionId}");
if ($session && $session['user_id'] === $userId) {
// ✅ Safe
}
7. Rotate Session IDs#
After privilege escalation or sensitive operations:
Route::post('/change-password', function(Request $r) {
$oldSessionId = $r->cookie('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',
'sess=' . $newSessionId . '; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
);
});
Testing Cookies#
Unit Test#
use PHPUnit\Framework\TestCase;
class CookieTest extends TestCase
{
public function testSetsCookieWithCorrectAttributes(): void
{
$response = Response::json(['ok' => true])
->withAddedHeader('Set-Cookie',
'test=value; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
);
$setCookie = $response->getHeaderLine('Set-Cookie');
$this->assertStringContainsString('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' => 'sess=abc123; theme=dark'],
method: 'GET',
uri: '/',
);
$this->assertEquals('abc123', $request->cookie('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"
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 is checked for session cookies✅
Securecolumn is checked (in production)✅
SameSiteis set appropriately✅
Expires / Max-Agematches expectation✅
Pathis scoped correctly✅
Domainis minimal (or blank for same-origin)
Firefox DevTools#
Open DevTools (F12)
Go to Storage tab
Expand Cookies
Select your domain
Cookie Debugging Headers#
Add debug headers in development:
if ($_ENV['APP_ENV'] === 'development') {
$response = $response->withHeader('X-Debug-Cookies', json_encode([
'sent' => $request->getCookieParams(),
'set' => $response->getHeader('Set-Cookie')
]));
}
Checklist#
Prefer
HttpOnly; Secure; SameSiteon all cookiesEncrypt sensitive values with CookieEncryptionMiddleware
Keep cookie scope minimal (Path/Domain)
Clear cookies by setting
Max-Age=0/ pastExpiresNever trust cookie contents without validation
Related Resources#
Cookie Encryption Middleware - Transparent encryption
Security Guide - OWASP Top 10 and cookie security
Requests - Reading cookies from requests
Responses - Setting cookies on responses