4.2 KiB
4.2 KiB
| title | aliases | tags | sources | created | updated | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Double Submit Cookie CSRF Pattern for JWT APIs |
|
|
|
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_tokencookie is set alongsiderefresh_tokenat login (sameSet-Cookieresponse)/auth/refreshvalidates thatX-CSRF-Tokenrequest header matches thecsrf_tokencookie 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_tokenMUST also setcsrf_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
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",
});
Related Concepts
- wiki/concepts/etag-optimistic-locking — another HTTP header-based protection pattern
- wiki/tech-patterns/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