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:

  1. Access token → JavaScript memory (a variable). Short-lived (5–15 min). Sent via the Authorization header.
  2. Refresh token → HttpOnly; Secure; SameSite=Lax cookie. 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:

  1. The token was stolen and the attacker is replaying it, or
  2. 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):

  1. The server sets a CSRF token in a cookie (it doesn't need to be HttpOnly)
  2. Client JS reads that cookie and sends its value in a custom header (X-CSRF-Token)
  3. 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=Lax cookie
  • [ ] 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)
  • [ ] HSTS header with includeSubDomains; preload
  • [ ] Content-Security-Policy restricts script sources
  • [ ] X-Frame-Options: DENY on all authenticated pages
  • [ ] X-Content-Type-Options: nosniff on all responses
  • [ ] Cookie Domain scoped narrowly — don't set it on .example.com if only app.example.com needs it
  • [ ] Cookie Path scoped 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)

Further Reading