← Back to the section

After a user logs in, the server issues tokens — an access token and a refresh token. The question is: where will the browser put them? The answer directly affects the security of the whole application.

Why localStorage is a bad place for tokens

The first thought when building an SPA is to save the token in localStorage. It is convenient — the data lives across tabs and is easy to read.

// Never do this
localStorage.setItem('access_token', accessToken);

The problem is that localStorage is accessible to any JavaScript code on the page. If the site includes third-party analytics, an ad script, or an npm package with a vulnerability — they can read the token just as easily as your own code. One successful XSS request and the token goes to the attacker.

The solution is an HttpOnly cookie. The browser stores it itself and automatically attaches it to every request, but JavaScript has no access to it at all: document.cookie does not see it. It cannot be stolen via XSS.

HttpOnly cookies via FastAPI

FastAPI sets a cookie through the response.set_cookie method. For tokens you need to set three mandatory attributes:

from datetime import timedelta
from fastapi import APIRouter, Response
from application.use_cases.login import LoginUseCase, LoginCommand
from adapters.in.http.schemas import LoginRequest

router = APIRouter(prefix="/auth", tags=["auth"])

ACCESS_MAX_AGE = int(timedelta(minutes=15).total_seconds())
REFRESH_MAX_AGE = int(timedelta(days=7).total_seconds())


@router.post("/login", status_code=204)
async def login(req: LoginRequest, response: Response, use_case: LoginUseCase):
    tokens = await use_case.execute(LoginCommand(username=req.username, password=req.password))

    response.set_cookie(
        key="access_token",
        value=tokens.access_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
        max_age=ACCESS_MAX_AGE,
    )
    response.set_cookie(
        key="refresh_token",
        value=tokens.refresh_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/auth/refresh",
        max_age=REFRESH_MAX_AGE,
    )

What each attribute does:

  • httponly=True — JavaScript does not see the cookie via document.cookie. This is the main protection against XSS.
  • secure=True — the browser sends the cookie only over HTTPS. Without this attribute the token can travel over an open connection.
  • samesite="lax" — the cookie is not sent on cross-site POST requests. This protects against CSRF attacks. The value "lax" allows it to be sent when following a link from another site (for example, the user opened a link in a new tab and stayed logged in). "strict" is stricter but breaks that scenario.
  • path="/auth/refresh" on the refresh cookie — the cookie will only be sent to one specific endpoint rather than to all API requests. This narrows the surface where the refresh token might accidentally end up in logs or a handler.
  • max_age — an explicit lifetime. Without it the cookie becomes a session cookie and disappears when the browser closes, but that is not the same as "safely expired".

Refresh token rotation

An access token lives for 15 minutes. When it expires, the client requests a new one by presenting the refresh token. Here an important detail appears: every successful refresh must issue a new refresh token and invalidate the old one.

from fastapi import APIRouter, Cookie, Response, HTTPException
from application.use_cases.refresh_tokens import RefreshTokensUseCase, RefreshCommand

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/refresh", status_code=204)
async def refresh(
    response: Response,
    use_case: RefreshTokensUseCase,
    refresh_token: str | None = Cookie(default=None),
):
    if not refresh_token:
        raise HTTPException(status_code=401, detail="no refresh token")

    tokens = await use_case.execute(RefreshCommand(refresh_token=refresh_token))

    response.set_cookie(
        key="access_token",
        value=tokens.access_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
        max_age=ACCESS_MAX_AGE,
    )
    response.set_cookie(
        key="refresh_token",
        value=tokens.refresh_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/auth/refresh",
        max_age=REFRESH_MAX_AGE,
    )

Why change the refresh token on every renewal:

T=0    Login → access_token (15 min) + RT-1 (7 days)
T=15m  POST /auth/refresh with RT-1 → new access_token + RT-2, RT-1 invalidated
T=30m  POST /auth/refresh with RT-2 → new access_token + RT-3
T=31m  Attacker found RT-2 in the logs → POST /auth/refresh with RT-2
        Server: RT-2 already used — invalidate the whole chain
        The legitimate user is logged out and must sign in again

Reuse of an already-consumed token is a sign of compromise. The server cannot know who presented the old token — the legitimate user or an attacker — so the correct reaction is to invalidate the whole chain and require a fresh login. This causes inconvenience to the user in the rare case of a request race, but it protects against token theft.

BFF — tokens never reach the browser at all

There is a stricter approach: the FastAPI service acts as an intermediary (Backend for Frontend, BFF). Tokens are stored only on the server — for example, in Redis. The browser receives only an opaque session identifier.

Browser (cookie: SESSION=abc123)
    ↓
FastAPI BFF (Redis: abc123 → { access_token: ..., refresh_token: ... })
    ↓  (BFF adds Authorization: Bearer ...)
Downstream services
import secrets
from fastapi import APIRouter, Response
from adapters.out.session.redis_session_store import RedisSessionStore
from application.use_cases.login import LoginUseCase, LoginCommand
from adapters.in.http.schemas import LoginRequest

router = APIRouter(prefix="/bff/auth", tags=["bff-auth"])

SESSION_MAX_AGE = 60 * 60 * 24 * 7  # 7 days


@router.post("/login", status_code=204)
async def bff_login(
    req: LoginRequest,
    response: Response,
    use_case: LoginUseCase,
    session_store: RedisSessionStore,
):
    tokens = await use_case.execute(LoginCommand(username=req.username, password=req.password))
    session_id = secrets.token_urlsafe(32)
    await session_store.put(session_id, tokens, ttl=SESSION_MAX_AGE)

    response.set_cookie(
        key="SESSION",
        value=session_id,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
        max_age=SESSION_MAX_AGE,
    )

Advantages of BFF: tokens never leave the server, and forced logout and limiting the number of sessions are implemented centrally.

The downside: server-side state. You need Redis or another session store, and the BFF is a separate piece of infrastructure.

For most applications a JWT in an HttpOnly cookie (the stateless approach) is enough. BFF is justified for complex SPAs with long-lived sessions and requirements for centralized management of them.

CSRF and HttpOnly cookies

An HttpOnly cookie closes off XSS, but it opens another gap: CSRF. The browser automatically attaches the cookie to any request, including a request from a malicious site. For example, a third-party page makes POST /api/orders/cancel?id=42 — the browser sends the cookie, and the server does not know that the request did not come from the user.

SameSite=Lax reduces this risk: the cookie will not be sent on a cross-site POST. For more reliable protection, add a Double Submit Cookie:

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import secrets

CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
CSRF_COOKIE_NAME = "csrftoken"
CSRF_HEADER_NAME = "x-csrftoken"


class CsrfMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.method not in CSRF_SAFE_METHODS:
            cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
            header_token = request.headers.get(CSRF_HEADER_NAME)
            if not cookie_token or cookie_token != header_token:
                return Response(status_code=403, content="csrf validation failed")
        response = await call_next(request)
        if CSRF_COOKIE_NAME not in request.cookies:
            response.set_cookie(
                key=CSRF_COOKIE_NAME,
                value=secrets.token_urlsafe(32),
                httponly=False,
                samesite="lax",
                secure=True,
            )
        return response

The principle: the CSRF cookie is deliberately without httponly, so that JavaScript can read it and send it in the x-csrftoken header. The server compares the value in the cookie and in the header. An attacker on another site cannot read the cookie from your domain, so they do not know the correct value and cannot form a valid header.

On logout it is not enough to simply stop sending the cookie. You need to explicitly delete them:

@router.post("/logout", status_code=204)
async def logout(response: Response):
    response.delete_cookie(key="access_token", path="/")
    response.delete_cookie(key="refresh_token", path="/auth/refresh")

delete_cookie sets max_age=0 and an expiration date in the past — the browser deletes the cookie immediately.

Common mistakes

JWT in localStorage. Any JavaScript on the page reads localStorage. An HttpOnly cookie solves this: the value is inaccessible from code.

httponly=False for a token. If the token is in a cookie but httponly is not set — protection against XSS does not work. JavaScript still reads document.cookie.

secure=False in production. Without this attribute the browser will send the cookie over HTTP. In local development without HTTPS secure=False is acceptable; in production it is not.

samesite not specified. Browsers behave differently by default. Explicitly set at least "lax".

path="/" for the refresh cookie. The refresh token will be attached to all requests. If an endpoint logs headers, the token can end up in the log. Restrict it with path="/auth/refresh".

Refresh without rotation. If the same refresh token can be used many times, its theft goes unnoticed. Every renewal must issue a new token and invalidate the old one.

In short

  • You cannot store tokens in localStorage — any script on the page reads it. Use an HttpOnly cookie.
  • An HttpOnly cookie is inaccessible to JavaScript: XSS cannot steal it.
  • Three mandatory attributes: httponly=True, secure=True, samesite="lax".
  • Restrict the refresh cookie with path="/auth/refresh" — it is not needed for all requests.
  • Refresh token rotation: every successful /auth/refresh issues a new RT and invalidates the old one.
  • Reuse of an already-consumed RT is a sign of compromise; the right response is to invalidate the whole chain.
  • The BFF approach: tokens are stored only on the server in Redis, and the browser receives only a session identifier.
  • On logout, delete the cookies explicitly via response.delete_cookie.

Further reading

  • JWT validation in Python — validating the incoming token via PyJWKClient + PyJWT.
  • Service-to-service authentication — inter-service traffic does not use HTTP cookies.
  • RBAC: roles — Depends(require_roles(...)) on every endpoint.