---
title: "Double Submit Cookie CSRF Pattern for JWT APIs"
aliases: [double-submit-cookie, csrf-jwt, csrf-stateless, x-csrf-token]
tags: [security, csrf, jwt, fastapi, auth, cookies]
sources:
  - "daily/2026-04-29.md"
created: 2026-04-29
updated: 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.

### Double Submit Cookie Flow

```
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

```python
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

```python
# 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

```typescript
// 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",
});
```

## Related Concepts

- [[wiki/concepts/etag-optimistic-locking|etag-optimistic-locking]] — another HTTP header-based protection pattern
- [[wiki/tech-patterns/azure-ad-msal-auth|azure-ad-msal-auth]] — Oliver standard auth (MSAL handles CSRF differently via PKCE)

## Sources

- [[daily/2026-04-29.md]] — session 19:06, CSRF protection for stateless JWT refresh endpoint
