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.
Same-origin reverse proxy (recommended)
The cleanest production layout:
https://acme.com/ → web (landing + dashboard)
https://acme.com/api/* → api app
https://acme.com/docs/* → docs siteCaddyfile:
acme.com {
reverse_proxy /api/* api:4000
reverse_proxy /docs/* docs:5181
reverse_proxy * web:5191
}With this:
- One origin to the browser =
SameSite=strictworks - No
Domainattribute 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 requestsSameSite=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.
Cookie secret
# 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_SECRETis 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.cookiefor the session - CORS allow-list is exact origins (no
*with credentials) - Sign-out POST endpoint exists and clears the cookie
- Different
AUTH_SECRETper environment - Session
Max-Agematches 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.cookieto 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