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.
Hand-rolled cookie serialize / parse
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,
});CSRF: the cookie is ambient
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:, 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 aGETagainst your origin -- an<img src>, a<link>, anEventSource-- and the cookie rides along with none of them needing your permission. That's tolerated becauseGETis 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 aGEThandler ever mutates state.Origin allowlist, compared as a tuple. Parse the
Originheader withnew URL()and compare(scheme, host, port)against an explicit allowlist -- never string-prefix or substring matching.https:starts with/ / app. example. com. evil. example https:as a string; it is not the same origin./ / app. example. com No
Originheader -> refuse. Modern browsers sendOriginon every state-changing fetch/XHR/form submission, same-origin or not. A state-changing request with noOriginheader at all is either a very old browser or a non-browser client forging headers -- refuse it rather than falling back toRefereror assuming same-origin.Origin: null-> reject explicitly. Sandboxed iframes (sandboxwithoutallow-same-origin), some redirect chains, andfile:pages all send the literal string/ / "null"as the Origin. It will never legitimately match your allowlist, but a naive comparison (a permissive regex, anincludes()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-originis a useful defense-in-depth check, but "site" is the registrable domain only -- it ignores port. Two apps onapp.example.com:8787andapp.example.com:8788are different origins (and must be treated as such by the Origin allowlist above), yet both report assame-site. If this cookie is shared across subdomains withDomain=.example.com(see above),Sec-Fetch-Sitealone would trust any app on any port under that domain. The Origin tuple comparison is the one that has to include port;Sec-Fetch-Siteonly 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 Unauthorizedimmediately, 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 beforeWhy 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 /. 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
Auth with Pages Functions -- token validation in Pages Functions and the Auth0 integration pattern.