obsidian/wiki/concepts/double-submit-cookie-csrf.md
2026-04-29 21:58:01 +01:00

4.2 KiB

title aliases tags sources created updated
Double Submit Cookie CSRF Pattern for JWT APIs
double-submit-cookie
csrf-jwt
csrf-stateless
x-csrf-token
security
csrf
jwt
fastapi
auth
cookies
daily/2026-04-29.md
2026-04-29 2026-04-29

Double Submit Cookie CSRF Pattern for JWT APIs

For stateless JWT APIs, the traditional Synchronizer Token Pattern (server-side session stores token) is impossible. The Double Submit Cookie pattern provides CSRF protection without server state.

Key Points

  • csrf_token cookie is set alongside refresh_token at login (same Set-Cookie response)
  • /auth/refresh validates that X-CSRF-Token request header matches the csrf_token cookie value
  • The browser cannot read the cookie value cross-origin (SameSite + HttpOnly still blocks JS reads from other origins) — attacker can't forge the header
  • Critical: EVERY code path that issues a refresh_token MUST also set csrf_token — missing even one (e.g. Microsoft OAuth callback) leaves users with refresh token but no CSRF cookie → silent 403s on next token refresh

Details

Why Not Synchronizer Token?

Synchronizer Token requires server-side session storage: server generates a token, stores it per-session, validates it on state-changing requests. Stateless JWT APIs have no session → impossible without adding Redis/DB session state.

POST /auth/login
← Set-Cookie: refresh_token=JWT...; HttpOnly; SameSite=Lax
← Set-Cookie: csrf_token=random_uuid; SameSite=Lax   ← NOT HttpOnly (JS reads it)

POST /auth/refresh
→ Cookie: refresh_token=JWT...; csrf_token=abc123
→ X-CSRF-Token: abc123                               ← JS reads cookie, puts in header
← 200 OK (tokens match)

# Cross-origin CSRF attack:
POST /auth/refresh (from evil.com)
→ Cookie: refresh_token=JWT...   ← browser sends automatically
→ X-CSRF-Token: ???              ← attacker can't read the cookie → cannot forge
← 403 Forbidden

FastAPI Implementation

import secrets

@router.post("/auth/login")
async def login(response: Response, credentials: LoginRequest):
    user = await authenticate(credentials)
    refresh_token = create_refresh_token(user.id)
    csrf_token = secrets.token_hex(32)

    response.set_cookie("refresh_token", refresh_token, httponly=True, samesite="lax")
    response.set_cookie("csrf_token", csrf_token, httponly=False, samesite="lax")
    return {"access_token": create_access_token(user.id)}

@router.post("/auth/refresh")
async def refresh(
    request: Request,
    x_csrf_token: str | None = Header(None, alias="X-CSRF-Token"),
):
    csrf_cookie = request.cookies.get("csrf_token")
    if not x_csrf_token or x_csrf_token != csrf_cookie:
        raise HTTPException(status_code=403, detail="CSRF validation failed")
    # ... issue new access token

All Login Paths Must Set CSRF

# COMMON MISTAKE: Microsoft OAuth callback only sets refresh_token
@router.get("/auth/callback/microsoft")
async def ms_callback(code: str, response: Response):
    user = await exchange_ms_code(code)
    refresh_token = create_refresh_token(user.id)
    response.set_cookie("refresh_token", refresh_token, httponly=True)
    # ← MISSING: csrf_token not set here!
    # Result: user logs in via MS → no csrf cookie → 403 on first /auth/refresh

The fix: extract a set_auth_cookies(response, user_id) helper that always sets both cookies, call it from every login path.

Frontend

// Read csrf_token from cookie (it's NOT httpOnly)
function getCsrfToken(): string | null {
  return document.cookie
    .split("; ")
    .find(row => row.startsWith("csrf_token="))
    ?.split("=")[1] ?? null;
}

// Include in every refresh request
await fetch("/auth/refresh", {
  method: "POST",
  headers: { "X-CSRF-Token": getCsrfToken() ?? "" },
  credentials: "include",
});

Sources