The short version: store access tokens in JavaScript memory, put refresh tokens in HttpOnly/Secure/SameSite cookies, rotate refresh tokens on every use, and enforce CSRF protection with SameSite plus a double-submit cookie or anti-CSRF header. Everything else — localStorage JWTs, long-lived access tokens, missing cookie flags — is a hole waiting to be exploited.
Express.js route — server/routes/auth.js:
// Set a secure session cookie (Express.js example)
res.cookie('refresh_token', token, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'Lax', // blocks most CSRF
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
That's the pattern. Everything below explains why, and what to do when your architecture is more complex.
Why Authentication Is Only Half the Battle
Passkeys, Authelia, MFA, strong passwords — all of that protects the login event. Once the server issues a session token or JWT, your security posture depends entirely on how that token is stored, transmitted, rotated, and revoked.
Real incidents make the point:
| Incident | What Went Wrong |
|---|---|
| Uber, 2022 | An attacker who had bought a contractor's stolen password bombarded them with MFA push prompts until one was approved. From there, the attacker found hardcoded admin credentials sitting in a script on an internal network share, which unlocked Uber's privileged access management tooling — and from that, AWS, GCP, Slack, and more. |
| Capital One, 2019 | A misconfigured, over-privileged WAF was tricked (via SSRF) into pulling temporary AWS credentials from the instance metadata service, which the attacker then used to read data from S3 buckets it should never have had access to. |
| GitHub / npm, 2022 | An attacker used OAuth tokens stolen from two third-party integrators (Heroku and Travis CI) to enumerate victim organizations on GitHub and clone private repositories, npm's among them. |
| npm "Shai-Hulud" worm, 2025 | Self-propagating malware in compromised npm packages ran on postinstall, scanned developer machines and CI runners for npm tokens, GitHub PATs, and cloud credentials, then used those stolen credentials to publish more infected packages automatically. |
None of these were password problems. They were all about what happens after login — how a credential, token, or session is stored, scoped, and allowed to propagate.
Cookie Security Flags Deep Dive
Three flags control how browsers handle session cookies. Miss any one and you expose tokens to a different class of attack.
HttpOnly
Set-Cookie: session=abc123; HttpOnly
- What it does: Blocks JavaScript from reading the cookie via
document.cookie - What it stops: XSS token theft — an injected script can't exfiltrate the token
- Trade-off: Client-side JS can't read the token to attach it as a custom header
Secure
Set-Cookie: session=abc123; Secure
- What it does: Cookie is only sent over HTTPS connections
- What it stops: Interception over plain HTTP
- Trade-off: Breaks local HTTP development unless you use a local cert (mkcert works well here) or explicitly relax the flag in dev
SameSite
Set-Cookie: session=abc123; SameSite=Lax
Three modes:
| Value | Behavior | CSRF Protection | Use Case |
|---|---|---|---|
Strict |
Cookie never sent on cross-origin requests | Strongest | High-security apps; breaks some OAuth/SSO redirect flows |
Lax |
Cookie sent on top-level GET navigations, withheld on cross-origin subrequests | Good default | Most web apps |
None |
Cookie sent on all cross-origin requests (requires Secure) |
None on its own | Cross-domain APIs, embeds, widgets |
Where the browsers actually stand today: Chrome made Lax the default for any cookie missing the attribute back in Chrome 80 (February 2020). Firefox followed with the same default behavior in Firefox 96 (January 2022). Safari/WebKit was the holdout, but it adopted the same default-to-Lax behavior with iOS 18 and macOS Sequoia in 2024. So as of today, all three major engines default missing-attribute cookies to Lax — the old "Firefox and Safari still default to None" advice is out of date.
That doesn't mean you can skip the attribute. Older browser versions in the wild still use the legacy behavior, and SameSite=None cookies are rejected outright unless Secure is also set. Set it explicitly every time — don't lean on a browser default that varies by version.
Combined — The Gold Standard
Set-Cookie: refresh_token=xyz789; HttpOnly; Secure; SameSite=Lax; Path=/auth/refresh; Max-Age=604800
Where to Store Tokens
This is the most common mistake. Storage location determines your threat model.
| Storage | XSS Risk | CSRF Risk | Survives Tab Close | JS Accessible |
|---|---|---|---|---|
localStorage |
High — any script reads it | Low (not auto-sent) | Yes | Yes |
sessionStorage |
High — any script reads it | Low (not auto-sent) | No | Yes |
HttpOnly cookie |
None — JS can't read it | Yes — auto-sent | Configurable | No |
| JS variable (memory) | Low (cleared on navigation) | Low | No | Yes |
The current consensus:
- Access token → JavaScript memory (a variable). Short-lived (5–15 min). Sent via the
Authorizationheader. - Refresh token →
HttpOnly; Secure; SameSite=Laxcookie. Long-lived (7–30 days). Only sent to the refresh endpoint.
This pairing gives you XSS resistance (the access token never touches the DOM or storage APIs) and CSRF resistance (the refresh token is protected by SameSite plus an anti-CSRF check on the refresh call itself).
Why not just put the access token in an HttpOnly cookie too?
If the access token lives in an HttpOnly cookie, the browser auto-attaches it to every request to that domain — whether the endpoint needs it or not — and now every state-changing endpoint needs CSRF protection. Keeping it in memory and attaching it via the Authorization header means it's only sent when your own JS code explicitly does so.
JWT vs Opaque Session Tokens
| Dimension | JWT | Opaque Session ID |
|---|---|---|
| Format | Self-contained, signed payload | Random string, server-side lookup |
| Validation | Verify signature locally | Database/cache lookup required |
| Revocation | Hard (needs a denylist or a short TTL) | Easy (delete from the store) |
| Scalability | Stateless — no shared store needed | Needs a shared session store |
| Size | Roughly 500–2000 bytes | 20–64 bytes |
| Contains data | Claims (user ID, roles, expiry) | Nothing — it's a pointer to server state |
| Best for | Microservices, API gateways, SPAs | Monoliths, server-rendered apps |
When to pick JWT: multiple services need to validate tokens without calling a central auth server; API gateways; mobile apps hitting distributed backends.
When to pick opaque sessions: a single backend, server-rendered HTML, a need for instant revocation, or wanting the smallest possible cookie.
Hybrid approach: a short-lived JWT access token (5–15 min) plus an opaque, server-tracked refresh token. The access token validates fast across services; the refresh token revokes instantly.
Token Lifetime Strategy
The Problem
Long-lived access tokens mean a wide revocation window — if one leaks, the attacker has access until it expires. Short-lived tokens mean more frequent refresh calls, a bit more latency, and more load on the refresh endpoint.
The Solution: Short Access + Rotating Refresh
| Token | Lifetime | Storage | Revocation |
|---|---|---|---|
| Access token | 5–15 min | JS memory | Wait for expiry (or denylist for critical actions) |
| Refresh token | 7–30 days | HttpOnly cookie + server-side store | Delete from store = instant lockout |
Refresh Token Rotation
Every time a refresh token is used, issue a new refresh token and invalidate the old one. This limits the window in which a stolen refresh token is useful.
Client Server
|--- POST /auth/refresh -------->|
| (old refresh token) |
| |-- validate old token
| |-- issue new access + refresh
| |-- invalidate old refresh token
|<-- new access + refresh -------|
Reuse Detection
If a previously-used refresh token shows up again, it means one of two things:
- The token was stolen and the attacker is replaying it, or
- A race condition from concurrent requests
Response: invalidate the entire refresh token family (every token issued from that login session) and force re-authentication.
Pseudocode — auth/refresh.py:
def refresh(old_token):
record = db.get_refresh_token(old_token)
if record.used:
# REUSE DETECTED — invalidate family
db.invalidate_family(record.family_id)
raise AuthError("Token reuse detected. Session terminated.")
record.used = True
db.save(record)
new_refresh = create_refresh_token(family_id=record.family_id)
new_access = create_access_token(user_id=record.user_id)
return new_access, new_refresh
Token Revocation Approaches
JWTs are self-validating — the server doesn't check a database on every request. That's a feature (fast, stateless) and a problem (you can't revoke one mid-lifetime without extra machinery).
Approach 1: Denylist (Blocklist)
Store revoked token IDs (JTIs) in a fast cache (Redis) with a TTL matching the token's expiry.
Revocation hook — auth/revoke.py:
# Revoke on logout / password change / account lockout
redis.setex(f"deny:{jti}", ttl_seconds, "1")
Pros: works with existing JWTs; fine-grained (per-token). Cons: reintroduces state; a cache miss means the token is still treated as valid; adds latency to every request.
Approach 2: Short-Lived + No Revocation
Set the access token TTL to 5 minutes and accept that a stolen token is valid for up to 5 minutes. Rely on refresh token revocation for anything longer.
Pros: truly stateless; simplest architecture. Cons: a 5-minute window after any compromise.
Approach 3: Token Families with Server-Side Tracking
Store every issued token in a database. On each request, check that it exists and hasn't been revoked.
Pros: full control, full audit trail. Cons: defeats the JWT's stateless advantage; expensive at scale.
Recommendation
| Security Need | Approach |
|---|---|
| Standard web app | Short-lived access (15 min) + rotating refresh |
| High-security (banking, healthcare) | Short-lived access (5 min) + denylist for critical actions |
| Instant revocation required | Opaque sessions (skip JWT entirely) |
CSRF Defense for Modern Apps
Cross-Site Request Forgery tricks the browser into sending an authenticated request to your API from an attacker's page. Cookies are auto-attached by the browser — the attacker never sees the cookie's value, but the request still carries it.
Layer 1: SameSite Cookies
Set SameSite=Lax (or Strict). This blocks most CSRF by default — the browser refuses to attach the cookie on cross-origin POST requests.
Limitation: doesn't protect GET requests with side effects (bad practice, but it exists), and doesn't protect against attacks from same-site subdomains.
Layer 2: Anti-CSRF Tokens
For state-changing requests (POST, PUT, DELETE), verify a token the attacker can't know.
Double-Submit Cookie Pattern (stateless, ideal for SPAs):
- The server sets a CSRF token in a cookie (it doesn't need to be HttpOnly)
- Client JS reads that cookie and sends its value in a custom header (
X-CSRF-Token) - The server checks that the header value matches the cookie value
Client-side fetch call and matching server check:
# Server sets CSRF cookie
Set-Cookie: csrf_token=abc123; Secure; SameSite=Lax; Path=/
# Client reads cookie, sends header
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': getCookie('csrf_token') // read from cookie
},
body: JSON.stringify({ amount: 100 })
});
# Server validates: cookie_csrf_token == header_csrf_token
Why this works: the attacker's page can trigger a cross-origin request (the browser sends the cookie), but the attacker's JS can't read the cookie's value under the same-origin policy — so it can't put the matching value in the header.
Signed variant (stronger): HMAC the CSRF token with a server secret, so it can't be forged even if the cookie value is otherwise guessable.
Layer 3: Origin / Referer Header Check
Verify the Origin or Referer header matches your domain on state-changing requests.
Django middleware — middleware.py:
ALLOWED_ORIGINS = {"https://app.example.com"}
def check_origin(request):
origin = request.META.get("HTTP_ORIGIN", "")
if origin and origin not in ALLOWED_ORIGINS:
return HttpResponseForbidden("Invalid origin")
Limitation: some privacy extensions strip Referer, and Origin isn't always sent on same-origin requests. Treat this as defense-in-depth, not a primary control.
CSRF Strategy by Architecture
| Architecture | Primary CSRF Defense |
|---|---|
| Server-rendered + session cookie | Synchronizer token (per-request, stored server-side) |
| SPA + cookie-based auth | SameSite=Lax + double-submit cookie |
SPA + memory JWT (Authorization header) |
Not vulnerable — tokens aren't auto-sent |
| Cross-domain API | SameSite=None + anti-CSRF header + strict origin check |
Security Headers Checklist
Beyond cookies and tokens, these HTTP headers harden the surface a user hits after they're logged in.
Nginx config — /etc/nginx/sites-available/app.conf:
# Add to all authenticated responses
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
| Header | What It Stops |
|---|---|
Strict-Transport-Security (HSTS) |
SSL-stripping attacks — forces HTTPS |
X-Content-Type-Options: nosniff |
MIME confusion attacks |
X-Frame-Options: DENY |
Clickjacking — blocks iframe embedding |
Content-Security-Policy (CSP) |
XSS — restricts script sources and inline scripts |
Referrer-Policy |
Leaking tokens/IDs in the Referer header to third parties |
Permissions-Policy |
Limits browser API access (camera, mic, geolocation) |
CSP for SPAs: start with default-src 'self' and add sources as you actually need them. Use report-uri or report-to to collect violations before you enforce.
Decision Matrix
Pick your strategy based on architecture, not opinion.
| Scenario | Session Type | Storage | CSRF Strategy | Token Lifetime |
|---|---|---|---|---|
| Server-rendered monolith | Opaque session ID | Server store + HttpOnly cookie | Synchronizer token | 30 min idle timeout |
| SPA + same-domain API | JWT access + refresh | Memory + HttpOnly cookie | SameSite + double-submit | 15 min access / 7 day refresh |
| SPA + cross-domain API | JWT access + refresh | Memory + HttpOnly (SameSite=None) |
Anti-CSRF header + origin check | 15 min access / 7 day refresh |
| Mobile app + API | JWT | Secure device storage (Keychain/Keystore) | Not applicable (no browser) | 15 min access / 30 day refresh |
| Microservice mesh | JWT (service-to-service) | Env variable or vault | mTLS + audience restriction | 5 min access |
| High-security (banking) | Opaque session + denylist | Server store + HttpOnly cookie + hardware binding | Synchronizer token + step-up auth | 5 min session / re-auth for sensitive ops |
Summary Checklist
Before shipping, verify each item:
- [ ] Access token stored in JS memory, not localStorage or sessionStorage
- [ ] Refresh token in
HttpOnly; Secure; SameSite=Laxcookie - [ ] Access token lifetime ≤ 15 minutes
- [ ] Refresh token rotation enabled — new token issued on every refresh
- [ ] Refresh token reuse detection — family invalidation on replay
- [ ] Logout endpoint revokes the refresh token server-side
- [ ] Password change / MFA change invalidates all refresh tokens for that user
- [ ] CSRF protection active for cookie-based auth (SameSite + anti-CSRF token)
- [ ]
HSTSheader withincludeSubDomains; preload - [ ]
Content-Security-Policyrestricts script sources - [ ]
X-Frame-Options: DENYon all authenticated pages - [ ]
X-Content-Type-Options: nosniffon all responses - [ ] Cookie
Domainscoped narrowly — don't set it on.example.comif onlyapp.example.comneeds it - [ ] Cookie
Pathscoped to relevant endpoints (e.g./auth/refresh) - [ ] All cookie/session secrets generated with cryptographic randomness (≥256 bits)
- [ ] No tokens in URL parameters (they end up in server logs, Referer headers, and browser history)