zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

HTTP-only Cookie Sessions

HTTP-only cookie sessions with refresh-token rotation on Workers

Overview

Workers have no cookie helper. The runtime hands you raw Set-Cookie strings and a raw Cookie request header -- nothing parses or serializes them for you. So you hand-roll both directions yourself, and getting the attributes right (HttpOnly, Secure, SameSite, Domain, Max-Age) is security-critical: a missing HttpOnly exposes the session to JavaScript, a missing Secure leaks it over plain HTTP.

This page covers an HTTP-only cookie session built on two tokens -- a short-lived access cookie and a long-lived refresh cookie -- with server-side rotation so the access cookie can be silently re-minted without a fresh login.

There is no res.cookie() on Workers. You build the Set-Cookie value as a string and append it to the response headers yourself.

// utils/cookies.ts
export interface CookieOptions {
  httpOnly?: boolean;
  secure?: boolean;
  sameSite?: 'Strict' | 'Lax' | 'None';
  path?: string;
  maxAge?: number;
  domain?: string;
}

export function parseCookies(cookieHeader: string): Record<string, string> {
  const cookies: Record<string, string> = {};
  if (!cookieHeader) {
    return cookies;
  }
  const pairs = cookieHeader.split(';');
  for (const pair of pairs) {
    const trimmed = pair.trim();
    if (!trimmed) continue;
    const eqIndex = trimmed.indexOf('=');
    if (eqIndex === -1) continue;
    const key = trimmed.substring(0, eqIndex).trim();
    const value = trimmed.substring(eqIndex + 1).trim();
    cookies[key] = value;
  }
  return cookies;
}

export function serializeCookie(name: string, value: string, options: CookieOptions): string {
  const parts: string[] = [`${name}=${value}`];

  if (options.httpOnly) {
    parts.push('HttpOnly');
  }
  if (options.secure) {
    parts.push('Secure');
  }
  if (options.sameSite) {
    parts.push(`SameSite=${options.sameSite}`);
  }
  if (options.path) {
    parts.push(`Path=${options.path}`);
  }
  if (options.maxAge !== undefined) {
    parts.push(`Max-Age=${options.maxAge}`);
  }
  if (options.domain) {
    parts.push(`Domain=${options.domain}`);
  }

  return parts.join('; ');
}

Each attribute is load-bearing

HttpOnly blocks document.cookie access (XSS can't read the token). Secure restricts the cookie to HTTPS. SameSite controls cross-site sending (Lax is the practical default for a same-site app with cross-origin links). Omitting any of these silently weakens the session -- there is no framework to fill in safe defaults for you.

Two-token model

A single long-lived session cookie is a liability: if it leaks, it's valid for its entire lifetime. The fix is two tokens:

  • Access token -- short-lived (maxAge: 900 = 15 minutes), sent on every request, carries the user identity.

  • Refresh token -- long-lived, sent only to the refresh endpoint, used solely to mint new access tokens.

Both are JWTs signed with the same secret. The thing that keeps them from being interchangeable is a server-side type claim baked into each token (access vs refresh), enforced at verification time:

// utils/jwt.ts
import { SignJWT, jwtVerify, decodeJwt } from 'jose';

import type { TokenPayload } from '../types/auth.js';

export async function createToken(
  payload: Omit<TokenPayload, 'iat' | 'exp'>,
  secret: string,
  expiresIn: string,
): Promise<string> {
  const secretKey = new TextEncoder().encode(secret);
  const token = await new SignJWT({ ...payload })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(secretKey);
  return token;
}

export async function verifyToken(
  token: string,
  secret: string,
  expectedType: 'access' | 'refresh',
): Promise<TokenPayload> {
  const secretKey = new TextEncoder().encode(secret);
  const { payload } = await jwtVerify(token, secretKey);
  const tokenPayload = payload as unknown as TokenPayload;
  if (tokenPayload.type !== expectedType) {
    throw new Error(`Expected token type "${expectedType}" but got "${tokenPayload.type}"`);
  }
  return tokenPayload;
}

Why the type claim matters

Without the type check, a refresh token -- which is long-lived -- would also pass verification as an access token. An attacker who captured a refresh token could use it directly as a session credential for its full lifetime. The if (tokenPayload.type !== expectedType) throw line is what forces a refresh token to go through the rotation endpoint and never act as an access token.

Refresh flow

The refresh endpoint reads the refresh_token cookie, verifies it is genuinely a refresh token, and mints a new short-lived access cookie. The cookie's Max-Age (900) is deliberately matched to the JWT's '15m' expiry so the cookie and the token expire together.

// handlers/refresh.ts
import type { Env } from '../index.js';
import { parseCookies, serializeCookie } from '../utils/cookies.js';
import { createToken, verifyToken } from '../utils/jwt.js';

export async function handleRefresh(request: Request, env: Env): Promise<Response> {
  const cookieHeader = request.headers.get('cookie') || '';
  const cookies = parseCookies(cookieHeader);
  const refreshToken = cookies['refresh_token'];

  if (!refreshToken) {
    return new Response(
      JSON.stringify({
        error: 'Unauthorized',
        message: 'No refresh token',
      }),
      {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }

  try {
    const payload = await verifyToken(refreshToken, env.JWT_SECRET, 'refresh');

    const newAccessToken = await createToken(
      {
        sub: payload.sub,
        email: payload.email,
        name: payload.name,
        picture: payload.picture,
        type: 'access',
      },
      env.JWT_SECRET,
      '15m',
    );

    const accessCookie = serializeCookie('access_token', newAccessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Lax',
      path: '/',
      maxAge: 900,
      domain: env.COOKIE_DOMAIN,
    });

    return new Response(JSON.stringify({ success: true }), {
      status: 200,
      headers: new Headers([
        ['Content-Type', 'application/json'],
        ['Set-Cookie', accessCookie],
      ]),
    });
  } catch {
    return new Response(
      JSON.stringify({
        error: 'Unauthorized',
        message: 'Invalid refresh token',
      }),
      {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}

The client never touches the tokens -- they are HttpOnly, so the browser stores and sends them automatically. When an access cookie expires, a request to the refresh endpoint transparently issues a new one as long as the refresh cookie is still valid.

Sharing the session across subdomains

Setting Domain= on the cookie lets it be sent to every subdomain of that domain. The value comes from a COOKIE_DOMAIN binding so it stays environment-specific (e.g. .example.com shares the session across app.example.com, api.example.com, and so on).

const accessCookie = serializeCookie('access_token', newAccessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'Lax',
  path: '/',
  maxAge: 900,
  domain: env.COOKIE_DOMAIN,
});

Every request from a browser to a matching origin carries the Cookie header automatically -- the browser doesn't check which page triggered the request, only which origin it's addressed to. That convenience is also the entire premise of Cross-Site Request Forgery: a page on evil.example can make the victim's browser fire POST https://app.example.com/api/refresh, and the access_token / refresh_token cookies ride along, fully authenticated, without the attacker ever seeing their value.

Scope the gate to browser-cookie routes. This check only belongs in front of routes authenticated by the ambient cookie set on this page. A route authenticated by a bearer token in an Authorization header doesn't need it -- attaching that header is something the calling code has to do on purpose (read the token from storage, set the header), and a page on another origin has no way to make the browser do that for it. CSRF exploits ambient credential attachment; a header credential was never ambient to begin with.

The gate itself is a handful of rules, each closing a specific hole:

  • Skip safe methods. Only gate POST/PUT/PATCH/DELETE. A cross-site page can still trigger a GET against your origin -- an <img src>, a <link>, an EventSource -- and the cookie rides along with none of them needing your permission. That's tolerated because GET is defined to be side-effect free: the attacker can make the browser fetch something under the victim's identity, but not change anything, and can't read the response either (no CORS grant). The whole scheme collapses if a GET handler ever mutates state.

  • Origin allowlist, compared as a tuple. Parse the Origin header with new URL() and compare (scheme, host, port) against an explicit allowlist -- never string-prefix or substring matching. https://app.example.com.evil.example starts with https://app.example.com as a string; it is not the same origin.

  • No Origin header -> refuse. Modern browsers send Origin on every state-changing fetch/XHR/form submission, same-origin or not. A state-changing request with no Origin header at all is either a very old browser or a non-browser client forging headers -- refuse it rather than falling back to Referer or assuming same-origin.

  • Origin: null -> reject explicitly. Sandboxed iframes (sandbox without allow-same-origin), some redirect chains, and file:// pages all send the literal string "null" as the Origin. It will never legitimately match your allowlist, but a naive comparison (a permissive regex, an includes() check) can be tricked by it -- reject it by name before it reaches the comparison.

  • Sec-Fetch-Site is a second signal, not a replacement. Sec-Fetch-Site: same-origin is a useful defense-in-depth check, but "site" is the registrable domain only -- it ignores port. Two apps on app.example.com:8787 and app.example.com:8788 are different origins (and must be treated as such by the Origin allowlist above), yet both report as same-site. If this cookie is shared across subdomains with Domain=.example.com (see above), Sec-Fetch-Site alone would trust any app on any port under that domain. The Origin tuple comparison is the one that has to include port; Sec-Fetch-Site only adds a cheap extra check on top of it.

  • Verify the session before running the gate. Check the cookie itself first. No valid session -> 401 Unauthorized immediately, before the CSRF check ever runs -- there's no ambient credential yet to protect. Valid session but the CSRF check fails -> 403 Forbidden. The status code tells the client which problem it has: "log in" versus "you're logged in, but this specific request is being refused."

// utils/csrf.ts
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

interface OriginTuple {
  scheme: string;
  host: string;
  port: string;
}

function parseOrigin(value: string): OriginTuple | null {
  try {
    const url = new URL(value);
    return {
      scheme: url.protocol,
      host: url.hostname,
      port: url.port || (url.protocol === 'https:' ? '443' : '80'),
    };
  } catch {
    return null;
  }
}

function isSameOrigin(a: OriginTuple, b: OriginTuple): boolean {
  return a.scheme === b.scheme && a.host === b.host && a.port === b.port;
}

export function requiresCsrfCheck(request: Request): boolean {
  return !SAFE_METHODS.has(request.method);
}

export function isTrustedOrigin(request: Request): boolean {
  const origin = request.headers.get('Origin');

  // Missing Origin on a state-changing request: refuse rather than fall
  // back to Referer or assume same-origin.
  if (!origin) {
    return false;
  }

  // Sandboxed iframes, some redirect chains, and file:// pages send the
  // literal string "null" -- it must never match the allowlist below.
  if (origin === 'null') {
    return false;
  }

  const parsed = parseOrigin(origin);
  if (!parsed) {
    return false;
  }

  for (const allowed of ALLOWED_ORIGINS) {
    const parsedAllowed = parseOrigin(allowed);
    if (parsedAllowed && isSameOrigin(parsed, parsedAllowed)) {
      return true;
    }
  }
  return false;
}

Wire it into a mutating handler after the session check and before the state change -- here, the refresh endpoint from above:

// handlers/refresh.ts (excerpt)
const payload = await verifyToken(refreshToken, env.JWT_SECRET, 'refresh');
// Reaching this line means the refresh token itself is valid -- an invalid
// or missing one throws and is caught by the existing 401 branch above.
// There is no ambient credential yet to protect until the session checks out.

if (requiresCsrfCheck(request) && !isTrustedOrigin(request)) {
  return new Response(
    JSON.stringify({
      error: 'Forbidden',
      message: 'Origin not allowed',
    }),
    {
      status: 403,
      headers: { 'Content-Type': 'application/json' },
    },
  );
}

// ...mint and return the new access cookie as before

Why SameSite=Lax, not Strict

The access and refresh cookies above use sameSite: 'Lax', not Strict. Lax withholds the cookie on cross-site POSTs and subresource requests (images, iframes, fetch) but still sends it on a top-level cross-site navigation using GET -- exactly what happens when an OAuth provider redirects the browser back to /auth/callback. Strict would drop the cookie on that first landing and break the login flow. The trade-off is that the "skip safe methods" rule above isn't optional: Lax only stays safe as long as no GET route performs a state change.

Wrangler config: secret vs vars

COOKIE_DOMAIN is public configuration and is fine in [vars]. JWT_SECRET is the signing key for every token -- it must never sit in wrangler.toml. Leave it blank in [vars] as a placeholder and inject the real value as a secret.

name = "auth-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"

[vars]
AUTH0_DOMAIN = ""
AUTH0_CLIENT_ID = ""
AUTH0_CLIENT_SECRET = ""
JWT_SECRET = ""
APP_URL = ""
COOKIE_DOMAIN = ""

Secrets vs Vars

The blank JWT_SECRET in [vars] is only a placeholder for local typing -- the real value is set with wrangler secret put JWT_SECRET and never committed. Anyone with the JWT secret can forge valid access and refresh tokens for any user.

See also

Revision History

CreatedUpdated