Handling Session Cookies in Edge Middleware
This guide is part of Authentication and Session Handling at the Edge. It covers the mechanics that trip people up: reading a cookie out of a header that may contain a dozen of them, attaching Set-Cookie to a response you are not allowed to mutate, and rotating a session without logging the user out.
The problem
The code looks right. You parse the cookie, verify the session, set a new one, and return the response. In the browser, nothing happens — the cookie is never stored. Or it stores, but disappears on the next navigation. Or two Set-Cookie headers collapse into one and only the last survives. Or everything works locally and silently fails in production because the cookie is not marked Secure.
Cookies are the oldest part of the web platform and the part with the most edge cases. Handling them from an edge isolate adds two more: response headers are frequently immutable, and Headers.set on set-cookie behaves differently from Headers.append.
Root cause: immutable responses and a header that is legitimately repeated
Two runtime facts explain most session-cookie bugs at the edge.
A Response you did not construct is immutable. When you await fetch(request) and get a response back, its headers object is guarded — calling set() on it throws TypeError: Can't modify immutable headers. You must construct a new Response around the original body, which is cheap because the body is a stream and is not copied.
Set-Cookie is the one header that legitimately appears multiple times. Every other header folds into a single comma-joined value; Set-Cookie does not, because cookie values may themselves contain commas in Expires dates. Headers.set('set-cookie', …) replaces all existing cookie headers. If you are issuing a session cookie and a preference cookie, set() will silently drop one of them. Use append().
Step 1 — Parse the cookie header defensively
The Cookie request header is a single string of name=value pairs separated by ; . Values may be percent-encoded, may contain =, and a malformed pair must not break parsing of the rest.
export function parseCookies(header: string | null): Map<string, string> {
const out = new Map<string, string>();
if (!header) return out;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 1) continue; // no name, or leading '='
const name = part.slice(0, eq).trim();
const raw = part.slice(eq + 1).trim();
if (!name || out.has(name)) continue; // first occurrence wins
try {
out.set(name, decodeURIComponent(raw));
} catch {
out.set(name, raw); // not encoded; keep it verbatim
}
}
return out;
}
Two deliberate choices. Splitting on the first = preserves base64 and JWT values, which contain padding = characters. And “first occurrence wins” matches browser behaviour when a subdomain has planted a duplicate cookie name — taking the last would let an attacker override the real one.
Step 2 — Build the cookie string with the right attributes
export interface SessionCookieOptions {
maxAgeSeconds: number;
sameSite?: 'Lax' | 'Strict' | 'None';
}
const COOKIE_NAME = '__Host-session';
export function serializeSession(value: string, opts: SessionCookieOptions): string {
const attrs = [
`${COOKIE_NAME}=${encodeURIComponent(value)}`,
'Path=/', // required by the __Host- prefix
'HttpOnly',
'Secure', // required by the __Host- prefix
`SameSite=${opts.sameSite ?? 'Lax'}`,
`Max-Age=${opts.maxAgeSeconds}`,
];
// NOTE: no Domain attribute — the __Host- prefix forbids it, and that is
// exactly what stops a sibling subdomain overwriting this cookie.
return attrs.join('; ');
}
export function serializeClear(): string {
return `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
}
Max-Age is preferred over Expires because it is a relative duration and therefore immune to client clock skew — relevant at the edge, where you have no reason to trust a browser’s idea of the current time.
To delete a cookie you must re-send it with Max-Age=0 and the same Path, Secure and prefix attributes. A clear that differs in any of those targets a different cookie and leaves the real one in place — the single most common cause of “logout does not log out”.
Step 3 — Attach cookies to a response you do not own
export function withCookies(response: Response, cookies: string[]): Response {
if (cookies.length === 0) return response;
const headers = new Headers(response.headers);
for (const cookie of cookies) {
headers.append('set-cookie', cookie); // append, never set
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
Anything carrying a Set-Cookie must also be uncacheable at shared tiers, or a CDN will hand one user’s session to the next visitor. Mark it explicitly:
headers.set('cache-control', 'private, no-store');
headers.append('vary', 'cookie');
The Vary: cookie addition is a safety net rather than a strategy — it is a blunt instrument that can shred your hit ratio, which is why varying edge cache by cookie exists as a topic of its own.
Step 4 — Rotate the session at every privilege change
Session fixation is simple to exploit and simple to prevent. If an attacker can cause a victim’s browser to hold a session identifier the attacker knows, and that identifier survives login, the attacker is now inside the authenticated session. The prevention is to mint a new identifier the moment privileges change and invalidate the old one.
interface SessionStore {
create(userId: string): Promise<string>;
destroy(sessionId: string): Promise<void>;
}
export async function rotateOnLogin(
store: SessionStore,
previousSessionId: string | undefined,
userId: string,
): Promise<string> {
const next = await store.create(userId);
// Destroy AFTER creating, so a failure mid-way leaves the user logged in
// with a valid session rather than logged out with none.
if (previousSessionId) await store.destroy(previousSessionId);
return serializeSession(next, { maxAgeSeconds: 60 * 60 * 8 });
}
Rotate on login, on any elevation of privileges, on password change, and on explicit logout. Do not rotate on every request: it multiplies writes to your session store for no security gain and breaks concurrent tabs, where an in-flight request still carries the previous identifier.
Configuration snippet
Session cookies must not be issued from routes that a CDN caches aggressively, so keep the matcher tight and pair it with an explicit runtime declaration.
export const config = {
runtime: 'edge',
matcher: ['/((?!_next/static|_next/image|favicon.ico|assets).*)'],
};
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
Secure cookies over http://localhost |
Accepted by Chrome, rejected by some others | Always over HTTPS, always accepted |
__Host- prefix |
Requires Secure, so may fail on plain HTTP |
Works normally |
| Response mutability | Dev server responses are often mutable | Upstream responses are guarded; set() throws |
Multiple Set-Cookie headers |
Node may fold them in some servers | Preserved as separate headers |
SameSite=None |
Needs Secure; silently dropped without it |
Same rule, enforced strictly |
| Cookie size limit | Rarely hit locally | 4 KB per cookie, ~8 KB total per domain |
The mutability row is the one that produces “worked locally, threw in production”. Always construct a new Response rather than mutating the one you were handed, even when the local server lets you get away with it.
Validating with Vitest
import { describe, expect, it } from 'vitest';
describe('parseCookies', () => {
it('keeps values containing = intact', () => {
const jar = parseCookies('__Host-session=eyJhbGci.payload.sig==; theme=dark');
expect(jar.get('__Host-session')).toBe('eyJhbGci.payload.sig==');
expect(jar.get('theme')).toBe('dark');
});
it('ignores malformed pairs without dropping the rest', () => {
const jar = parseCookies('broken; theme=dark; =novalue');
expect(jar.get('theme')).toBe('dark');
expect(jar.size).toBe(1);
});
it('takes the first occurrence of a duplicated name', () => {
const jar = parseCookies('__Host-session=real; __Host-session=planted');
expect(jar.get('__Host-session')).toBe('real');
});
});
describe('withCookies', () => {
it('appends rather than replacing existing cookies', () => {
const upstream = new Response('ok', { headers: { 'set-cookie': 'theme=dark; Path=/' } });
const out = withCookies(upstream, [serializeSession('abc', { maxAgeSeconds: 60 })]);
const all = out.headers.getSetCookie();
expect(all).toHaveLength(2);
expect(all.some((c) => c.startsWith('__Host-session='))).toBe(true);
});
it('does not throw on an immutable upstream response', () => {
const guarded = new Response('ok');
Object.freeze(guarded.headers);
expect(() => withCookies(guarded, ['a=1'])).not.toThrow();
});
});
Headers.getSetCookie() is the correct way to read back multiple cookie headers; headers.get('set-cookie') returns only a joined string and will make a two-cookie test pass when it should fail.
Common pitfalls and resolutions
Cookie never stored by the browser — missing Secure on an HTTPS origin, or a __Host- prefix combined with a Domain attribute. Drop Domain, keep Secure.
Only one of two cookies arrives — headers.set('set-cookie', …) used twice. Switch to append.
TypeError: Can't modify immutable headers — mutating an upstream response. Wrap it in a new Response.
Logout leaves the user signed in — the clearing cookie differs in Path, Secure or prefix from the one that was set. Match every attribute exactly.
Session lost on navigation from an external link — SameSite=Strict. Use Lax.
Random users see each other’s pages — a Set-Cookie response cached at a shared tier. Set cache-control: private, no-store on every response that issues a cookie.
Production deployment checklist
Frequently Asked Questions
Why does headers.set drop one of my cookies?
Set-Cookie is the only header that may legitimately repeat, and set replaces every existing instance of a header name. Use append so each cookie becomes its own header, and read them back with getSetCookie rather than get.
How do I delete a cookie from edge middleware?
Re-send it with Max-Age=0 and exactly the same Path, Secure, SameSite and name prefix as when it was set. Any difference in those attributes targets a different cookie and leaves the original in place.
Should I use Max-Age or Expires?
Max-Age. It is a relative duration, so it is immune to client clock skew, whereas Expires is an absolute timestamp interpreted against the browser’s clock, which you have no reason to trust.
Is it safe to cache a response that sets a session cookie?
No. Always mark it cache-control private, no-store. A shared cache that stores a Set-Cookie response will hand the same session to every subsequent visitor, which is one of the most severe caching bugs you can ship.
How often should the session identifier rotate?
At login, at any privilege elevation, at password change and at logout. Rotating on every request multiplies session-store writes for no security benefit and breaks concurrent tabs whose in-flight requests still carry the previous identifier.