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().

Re-wrapping an immutable response to attach cookies The upstream response has guarded headers, so calling set throws. Copying the headers into a mutable Headers object, appending Set-Cookie, and constructing a new Response around the original body stream succeeds. upstream Response headers: guarded .set() → TypeError new Headers(res.headers) mutable copy .append('set-cookie', …) new Response(res.body, …) body stream reused cookies attached ✓ set() replaces every Set-Cookie header append() is the only safe verb when issuing more than one cookie
Re-wrapping is not a copy: the body stream is handed straight to the new response, so the cost is a headers object and nothing else.

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.

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`;
}
Session cookie anatomy Each attribute of the session cookie string is annotated with what it prevents: the __Host- prefix blocks subdomain overwrite, HttpOnly blocks script access, Secure blocks plaintext transmission, SameSite blocks cross-site subrequests, and Max-Age makes expiry clock-independent. __Host-session=abc; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=28800 prefix — a subdomain cannot overwrite this cookie HttpOnly — XSS cannot read the value Secure — never sent over plaintext HTTP SameSite=Lax — blocks cross-site subrequests Max-Age — expiry independent of the client clock
Every attribute on the line is load-bearing. Dropping any one of them re-opens a specific, well-known attack.

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.

Session rotation timeline at login Before login the browser holds an anonymous session identifier. At login a new identifier is created and sent, then the previous one is destroyed, so a planted identifier cannot survive into the authenticated session. anonymous visit sid = A login succeeds create sid = B destroy sid = A authenticated sid = B only Without rotation, an attacker who planted sid = A shares the authenticated session.
Rotation costs one extra store write per login and closes session fixation completely.

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 arrivesheaders.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 linkSameSite=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.