Cookie security

The production cookie checklist — Secure, HttpOnly, SameSite, Domain, Path, Max-Age, CSRF defence.

A session cookie carries the keys to the user's account. Get the attributes wrong and you're handing them out to attackers. This page is the audit list.

The attributes that matter

Attribute Value Why
Secure true in production Cookie only sent over HTTPS. Without it, an attacker on the network reads the cookie out of an HTTP request.
HttpOnly always JS can't read it. Defeats XSS-extraction of session tokens.
SameSite lax (hardcoded) CSRF defence. Not configurable; the plugin never emits strict or none.
Domain unset (default) OR .your-product.com Unset = host-only (strict). Set = shared across subdomains.
Path / Always covers the whole app. Narrower paths cause subtle "cookie missing on some requests" bugs.
Max-Age configurable, default 7 days Session lifetime.

Production AuthConfig

const AUTH_CONFIG: AuthConfig = {
  secret:          process.env.AUTH_SECRET!,         // 32+ bytes from a CSPRNG
  defaultTenantId: 'public',
  cookieSecure:    true,                             // ALWAYS true in prod
  cookieDomain:    '.your-product.com',              // optional; for subdomain sharing
  successRedirect: 'https://app.your-product.com/',
}

AuthConfig is exactly { secret, defaultTenantId, cookieDomain?, cookieSecure?, successRedirect? }. SameSite is not configurable — the session builder hardcodes SameSite=lax. There is no cookieSameSite field.

SameSite is always lax

The cookie ships SameSite=lax. That's the right choice for the typical sign-in flow: lax cookies are sent on top-level GETs and cross-origin form POSTs, so the cookie lands before the post-sign-in redirect. strict would block cross-origin sign-in entirely, and none (cross-site cookies) is a footgun the framework simply doesn't emit. If you want the stricter same-origin posture, put api + web behind one reverse proxy (below) — same-origin requests carry the cookie regardless.

The cleanest production layout:

https://acme.com/         → web (landing + dashboard)
https://acme.com/api/*    → api app
https://acme.com/docs/*   → docs site

Caddyfile:

acme.com {
  reverse_proxy /api/*  api:4000
  reverse_proxy /docs/* docs:5181
  reverse_proxy *       web:5191
}

With this:

  • One origin to the browser = SameSite=strict works
  • No Domain attribute needed (host-only is fine)
  • No CORS preflight noise
  • One TLS certificate covers everything

This is the layout we recommend for any deploy that isn't multi-region.

Cross-origin (split api / web)

For multi-region + edge-cached web with the api in one region:

https://acme.com         → web (CDN-cached)
https://api.acme.com     → api (one region)

You need:

  • cookieDomain: '.acme.com' so the cookie is set by api.acme.com but flows on acme.com requests
  • SameSite=lax (already the hardcoded default) so form POST sign-in works
  • CORS allow-list on the api: Access-Control-Allow-Origin: https://acme.com + Access-Control-Allow-Credentials: true

The cookie boundary is the registrable domain (acme.com), so Domain=.acme.com covers both subdomains.

CSRF defence

SameSite=lax blocks GET-based CSRF + most cross-origin write attempts, and it's the first line of defence. On top of it, the plugin ships a signed double-submit CSRF token: GET /auth/csrf (handler handleCsrf) sets a readable voltro:csrf cookie + returns the token in JSON; the SPA echoes it in the x-csrf-token header on every state-changing call, and authRoutesPlugin() rejects authenticated mutations whose header and cookie don't match a server-signed token (verifyCsrf). The token is HMAC-signed so a cookie-injecting network attacker can't forge one.

# Generate a strong secret
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

Store it:

  • Production: in your secrets manager (AWS SM, Doppler, 1Password, Vercel env). NEVER commit to git.
  • Staging: different secret than production. Rotating one shouldn't kill the other.
  • CI: mocked or randomly-generated per-job for tests.

Rotation

Multi-key rotation ships. Set the new secret as VOLTRO_SESSION_SECRET and move the old one to VOLTRO_SESSION_SECRET_PREVIOUS; resolveSessionSecrets() returns both, signSession stamps the current key's kid, and verifySessionKeyed accepts either during the rotation window. Decommission the old key (drop _PREVIOUS) after the max session lifetime — nobody is signed out.

Audit checklist before going to prod

  • cookieSecure: true
  • AUTH_SECRET is 32+ bytes from a real CSPRNG, in a secrets manager
  • HTTPS terminates at the edge with HSTS preload enabled
  • Cookies are HttpOnly (verify in browser devtools → Application → Cookies)
  • No JavaScript reads document.cookie for the session
  • CORS allow-list is exact origins (no * with credentials)
  • Sign-out POST endpoint exists and clears the cookie
  • Different AUTH_SECRET per environment
  • Session Max-Age matches your risk profile (not 365 days for a banking app)

What the framework does NOT defend against

  • Server-side bugs that leak the cookie value (e.g. logging req.headers.cookie to an aggregator). Audit your logging pipeline.
  • Compromised user device. A keylogger on a user's machine sees the password. TOTP/MFA enrolment ships today (see HTTP handlers); passkeys ship too (phishing-resistant, no shared secret to keylog — see the auth plugin overview). Ultimately devices have to be trusted.
  • Compromised provider. If your secrets manager leaks AUTH_SECRET, every session is forgeable. Rotate immediately + invalidate every session.
  • Phishing. A user signing in on an attacker-controlled site → attacker has the cookie. Defence is browser-level (SSL pinning, password managers refusing to autofill on wrong domain). Passkeys help here directly: WebAuthn binds the credential to the origin, so an assertion produced on a phishing domain fails the origin/rpId check server-side.

For high-stakes apps (banking, healthcare), layer on:

  • Step-up auth for sensitive operations (re-prompt for a TOTP code or password before billing changes)
  • Device fingerprinting + new-device alerts
  • IP-based anomaly detection
  • Login notifications via email