Authentication Token Storage: JWT Bearer Tokens, Session Cookies, and JWT in HttpOnly Cookies

Token storage is a critical security choice. This article compares JWT bearer tokens, opaque session cookies, and JWTs in HttpOnly cookies—weighing revocation, XSS, and CSRF trade-offs—with FastAPI reference implementations for each.

Share
Authentication Token Storage: JWT Bearer Tokens, Session Cookies, and JWT in HttpOnly Cookies
Photo by FlyD / Unsplash

Selecting a token storage and transport strategy is one of the most consequential architectural decisions in application security. Three patterns dominate production systems: JWT bearer tokens transmitted via the Authorization header, opaque session identifiers stored in cookies, and JSON Web Tokens stored in HttpOnly cookies. Each represents a distinct position in the trade-off space between statelessness, revocability, and attack surface.

This article examines the security and operational properties of each approach, provides reference implementations in FastAPI, and offers concrete guidance on selecting the appropriate mechanism for a given client type.

The Three Patterns

JWT Bearer Token

The client holds a signed JSON Web Token and transmits it explicitly on each request:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Validation is cryptographic. The server verifies the signature and reads the embedded claims; no session store is consulted. The token itself constitutes the complete authentication state.

The server issues a cryptographically random identifier with no intrinsic meaning, persists the associated session data server-side (typically in Redis or PostgreSQL), and delivers the identifier as a cookie:

Set-Cookie: session_id=k7f9x2...; HttpOnly; Secure; SameSite=Lax

The browser transmits the cookie automatically. Each authenticated request resolves the identifier against the session store. All authentication state resides on the server.

A hybrid: the cookie value is a JWT rather than a random identifier. This combines stateless cryptographic validation with cookie-based transport and HttpOnly protection. As will become clear, it also inherits obligations from both parent patterns.

Security and Operational Properties

Server-Side State

Session cookies require a session store, and every authenticated request incurs a lookup. This cost is frequently overstated. A modestly provisioned Redis instance sustains hundreds of thousands of reads per second; for the vast majority of systems, session resolution is not a meaningful bottleneck.

JWT validation is stateless. Any service holding the verification key can authenticate requests independently. This property has genuine value in architectures with many autonomous services, or where the authorization server and resource servers are operated by separate teams.

Cross-Site Scripting (XSS) Exposure

Storage location determines the consequence of an XSS vulnerability.

A JWT held in localStorage is readable by any script executing in the page context. A single XSS flaw — whether in first-party code or a transitive dependency — permits exfiltration of the token, which remains valid from any origin until expiry. This is the decisive argument against browser-side JWT storage in localStorage, notwithstanding its continued prevalence in tutorials.

An HttpOnly cookie, regardless of its contents, is inaccessible to JavaScript. XSS retains the ability to issue authenticated requests from within the victim's browser session, but the credential itself cannot be extracted and reused elsewhere. The blast radius is materially smaller.

Cross-Site Request Forgery (CSRF)

Automatic cookie transmission is precisely the mechanism CSRF exploits. Both cookie-based patterns therefore require CSRF defenses: SameSite=Lax or Strict as the baseline, supplemented by CSRF tokens where cross-site requests must be supported.

Bearer tokens are structurally immune to CSRF. An attacking origin cannot cause the browser to attach an Authorization header it does not control. This is a genuine and often undervalued advantage of the bearer pattern.

Revocation

The central trade-off, stated plainly: statelessness and immediate revocation are mutually exclusive.

With server-side sessions, revocation is a single delete operation. Logout, account suspension, and removal of a user from an organization all take effect on the next request.

A signed JWT, by contrast, remains valid until expiry. The issuing server retains no record of it and possesses no mechanism to invalidate it. Two mitigations exist:

  1. Short-lived access tokens with refresh tokens. Access tokens expire within 5–15 minutes; the refresh flow validates against server-side state. Revocation propagates within one access-token lifetime. This is the standard approach.
  2. A denylist of revoked token identifiers, consulted on every request. This restores immediate revocation at the cost of reintroducing per-request state — the property statelessness was meant to eliminate.

Systems requiring immediate revocation — multi-tenant platforms in particular, where an administrator's removal of a user's role must take effect at once — are structurally misaligned with a pure JWT design.

Claim Staleness

Claims embedded in a JWT are fixed at issuance. Role changes, permission grants, and subscription modifications do not propagate to outstanding tokens. Server-side sessions reflect such changes on the next request.

The accepted mitigation is to keep JWTs minimal — subject identifier, session identifier, expiry — and resolve authorization server-side per request, with caching. This is sound practice, though it should be recognized as a partial reintroduction of server state.

Non-Browser Clients

Cookies are a browser mechanism. Mobile applications, command-line tools, third-party API consumers, and service-to-service communication are all better served by bearer tokens held in platform-appropriate secure storage (Keychain on iOS, Keystore on Android). The presence of a native mobile client alone precludes a cookie-only architecture.

Reference Implementations in FastAPI

The following implementations illustrate each pattern. Error handling is abbreviated for clarity; production deployments should include rate limiting, audit logging, and structured error responses.

import secrets
import json
from fastapi import FastAPI, Request, Response, HTTPException, Depends
import redis.asyncio as redis

app = FastAPI()
r = redis.Redis(decode_responses=True)

SESSION_TTL = 60 * 60 * 24 * 14  # 14 days

@app.post("/auth/login")
async def login(response: Response, credentials: LoginRequest):
    user = await authenticate(credentials.email, credentials.password)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    session_id = secrets.token_urlsafe(32)
    await r.setex(
        f"session:{session_id}",
        SESSION_TTL,
        json.dumps({"user_id": str(user.id), "org_id": str(user.org_id)}),
    )

    response.set_cookie(
        key="session_id",
        value=session_id,
        httponly=True,
        secure=True,
        samesite="lax",
        max_age=SESSION_TTL,
        path="/",
    )
    return {"status": "authenticated"}


async def get_current_user(request: Request) -> SessionData:
    session_id = request.cookies.get("session_id")
    if not session_id:
        raise HTTPException(status_code=401, detail="Not authenticated")

    raw = await r.get(f"session:{session_id}")
    if raw is None:
        raise HTTPException(status_code=401, detail="Session expired")

    return SessionData(**json.loads(raw))


@app.post("/auth/logout")
async def logout(request: Request, response: Response):
    session_id = request.cookies.get("session_id")
    if session_id:
        await r.delete(f"session:{session_id}")  # Immediate revocation
    response.delete_cookie("session_id")
    return {"status": "logged_out"}

Revocation is the deletion of a single key. Administrative revocation of all of a user's sessions is achieved by maintaining a secondary index (user_sessions:{user_id}) and deleting its members.

JWT Bearer Token with Refresh Rotation

from datetime import datetime, timedelta, timezone
import secrets
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

SECRET_KEY = settings.jwt_secret  # Load from a secrets manager
ALGORITHM = "HS256"
ACCESS_TTL = timedelta(minutes=10)
REFRESH_TTL = timedelta(days=30)

bearer_scheme = HTTPBearer()


def create_access_token(user_id: str, session_id: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "sid": session_id,   # Ties the token to a revocable session
        "iat": now,
        "exp": now + ACCESS_TTL,
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/auth/token")
async def issue_tokens(credentials: LoginRequest):
    user = await authenticate(credentials.email, credentials.password)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    session_id = secrets.token_urlsafe(16)
    refresh_token = secrets.token_urlsafe(32)

    # The refresh token is opaque and validated against server state
    await r.setex(
        f"refresh:{refresh_token}",
        int(REFRESH_TTL.total_seconds()),
        json.dumps({"user_id": str(user.id), "sid": session_id}),
    )

    return {
        "access_token": create_access_token(str(user.id), session_id),
        "refresh_token": refresh_token,
        "token_type": "bearer",
        "expires_in": int(ACCESS_TTL.total_seconds()),
    }


@app.post("/auth/refresh")
async def refresh(body: RefreshRequest):
    key = f"refresh:{body.refresh_token}"
    raw = await r.get(key)

    if raw is None:
        # Token is unknown or already used: possible replay.
        # A production system should revoke the entire token family here.
        raise HTTPException(status_code=401, detail="Invalid refresh token")

    data = json.loads(raw)
    await r.delete(key)  # Rotation: each refresh token is single-use

    new_refresh = secrets.token_urlsafe(32)
    await r.setex(
        f"refresh:{new_refresh}",
        int(REFRESH_TTL.total_seconds()),
        json.dumps(data),
    )

    return {
        "access_token": create_access_token(data["user_id"], data["sid"]),
        "refresh_token": new_refresh,
        "token_type": "bearer",
        "expires_in": int(ACCESS_TTL.total_seconds()),
    }


async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> TokenClaims:
    try:
        payload = jwt.decode(
            credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

    return TokenClaims(user_id=payload["sub"], session_id=payload["sid"])

Two design decisions warrant emphasis. First, the refresh token is opaque and stored server-side, preserving a revocation point: deleting the refresh key caps the attacker's window at one access-token lifetime. Second, refresh tokens are rotated on every use; presentation of an already-consumed token indicates replay and should trigger revocation of the entire token family.

@app.post("/auth/login")
async def login(response: Response, credentials: LoginRequest):
    user = await authenticate(credentials.email, credentials.password)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    token = create_access_token(str(user.id), secrets.token_urlsafe(16))

    response.set_cookie(
        key="access_token",
        value=token,
        httponly=True,
        secure=True,
        samesite="lax",
        max_age=int(ACCESS_TTL.total_seconds()),
        path="/",
    )
    return {"status": "authenticated"}


async def get_current_user(request: Request) -> TokenClaims:
    token = request.cookies.get("access_token")
    if not token:
        raise HTTPException(status_code=401, detail="Not authenticated")

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid or expired token")

    return TokenClaims(user_id=payload["sub"], session_id=payload["sid"])

Note that this pattern carries both sets of obligations: CSRF defenses, because transport is by cookie, and short token lifetimes with a refresh mechanism, because revocation is otherwise unavailable.

Selection Criteria

Opaque session cookies are the appropriate default for browser applications served from the same site as their API — the typical SaaS dashboard or administrative interface. They provide immediate revocation, fresh authorization state on every request, and a single authoritative record of active sessions. The per-request store lookup is negligible against these benefits.

JWT bearer tokens are the correct mechanism for non-browser clients: mobile applications, third-party integrations, and machine-to-machine communication. Access tokens should be short-lived and paired with opaque, server-validated refresh tokens under a rotation-with-reuse-detection regime. This preserves stateless validation on the request hot path while retaining a revocation lever at the refresh boundary.

JWT in an HttpOnly cookie is defensible for browser applications in service-dense architectures where many backends must validate requests independently and a revocation lag of several minutes is acceptable. The pattern should be adopted with full awareness that it requires both CSRF defenses and a refresh strategy.

JWT in localStorage should not be used for credentials of consequence. The XSS exposure is well documented, and a stolen token cannot be revoked.

A Composite Architecture

Products that serve both a web application and a mobile client — a common configuration — are best served by a split design over a shared session store:

  • Web: opaque session cookie (HttpOnly, Secure, SameSite=Lax), with session records in Redis. Revocation is immediate; authorization state is always current.
  • Mobile: short-lived JWT access token held in memory, with a long-lived opaque refresh token in secure platform storage. The refresh endpoint validates against the same session store.

Because both transports resolve to a common server-side session record, revoking a user terminates access across all surfaces within one access-token lifetime at most. Authorization is resolved server-side per request — cached against (user_id, org_id) where performance requires — so that permission changes propagate immediately.

Conclusion

The decision reduces to three questions:

  1. Is the client a browser? If not, bearer tokens are the only practical option.
  2. How quickly must revocation propagate? If immediately, server-side state is mandatory — either full sessions or session-backed refresh flows.
  3. Do many independent services validate requests? If so, stateless JWT validation justifies its operational complexity.

Select the transport per client type, keep tokens minimal and short-lived, and maintain a single server-side source of truth for active sessions. The resulting architecture is unremarkable by design — and it will not require an explanation, after an incident, of why a revoked credential continued to function.