Authentication
Two-token JWT strategy: how tokens are issued, stored, refreshed, and revoked.
Authentication is JWT-only and managed entirely by FastAPI. The frontend stores no credentials in localStorage or sessionStorage.
Two-token strategy
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access token | 15 minutes | In memory only (setAccessToken()) | Sent as Authorization: Bearer on every API request |
| Refresh token | 7 days | HttpOnly cookie | Used to obtain a new access token without re-logging in |
Why in-memory for the access token? Storing the access token in memory (not localStorage) means JavaScript running in a third-party script cannot read it, which limits XSS exposure. The trade-off is that the token is lost on page refresh — this is intentional. AuthSync (see below) transparently restores it from the refresh cookie on every page load.
Why HttpOnly for the refresh token? An HttpOnly cookie cannot be read by JavaScript at all, making it invisible to XSS attacks. The backend rotates this cookie on every refresh call.
Login flow
Client FastAPI
│── POST /api/auth/login ────▶ │ (form-encoded: email + password)
│ │ 1. Verify credentials
│ │ 2. Create usersession row
│ │ 3. Issue short-lived JWT (access token)
│ │ 4. Set HttpOnly refresh_token cookie (7 days)
│◀── 200 { access_token } ─── │
│ │
│ setAccessToken(access_token) (stored in module-level variable, never DOM)The login endpoint uses application/x-www-form-urlencoded encoding, not JSON.
Session restore on page load (AuthSync)
When the React app mounts, the AuthSync component (in App.tsx) fires immediately:
- Calls
GET /api/auth/refresh. - FastAPI reads the
refresh_tokencookie, validates the session, and returns a new access token (rotating the cookie). AuthSynccallssetAccessToken()with the new token.- Updates the Zustand auth store with the current user's profile.
This means a user who closed and reopened the browser is silently re-authenticated without a visible login screen, as long as the 7-day refresh cookie has not expired.
Automatic token refresh on 401
apiRequest() (in lib/queryClient.ts) handles token refresh transparently:
apiRequest() attaches the current in-memory access token as Authorization: Bearer <token>.
The access token has expired (15-minute lifetime).
Sends GET /api/auth/refresh. The HttpOnly cookie is sent automatically by the browser. FastAPI validates the session and returns a new access token.
setAccessToken() is called with the new token. The original request is retried with the fresh token. The caller never sees the 401.
If the refresh call itself returns 401 (expired or revoked session), the user is redirected to /login. This is the only case where the 401 surfaces to the UI.
force_password_reset guard
When an admin creates a user, the force_password_reset flag on the user row is set to true. Until the user changes their password, the backend returns 403 on all endpoints except:
GET /auth/me— so the frontend knows who is logged in and can show the change-password prompt.POST /auth/logout— so the user can log out even before changing their password.PATCH /users/{id}/password— the endpoint used to perform the password change.
The CallingUser dependency enforces this guard. To bypass it on a specific endpoint (e.g., /auth/me itself), pass allow_unchanged_password=True to the CallingUser constructor.
Logout flow
Client FastAPI
│── POST /api/auth/logout ───▶ │ 1. Delete usersession row (server-side)
│ │ 2. Clear the refresh_token cookie
│◀── 204 No Content ────────── │
Client: setAccessToken(null)
reset Zustand auth store
redirect to /loginLogout is complete even if the in-memory access token has not expired — the backend session is deleted, so the refresh token can no longer be used to obtain new access tokens.
Endpoints reference
| Method | Path | Auth required | Description |
|---|---|---|---|
POST | /auth/login | No | Issue tokens, start session |
GET | /auth/refresh | Cookie only | Rotate refresh token, return new access token |
GET | /auth/me | Yes | Return current user profile |
POST | /auth/logout | Yes | Delete session, clear cookie |