The JWT authentication lifecycle in a full-stack application has more moving parts than any single layer — it spans the React login form, the Zustand auth store, localStorage persistence, the Axios interceptor or RTK Query wrapper, the FastAPI token endpoint, and the FastAPI dependency that validates tokens on protected routes. Understanding the full lifecycle — every token issuance, storage, transmission, validation, refresh, and revocation — is what separates developers who can debug auth problems quickly from those who spend hours guessing.
Complete JWT Lifecycle
── REGISTRATION ──────────────────────────────────────────────────────────────
1. User fills register form → POST /api/auth/register
2. FastAPI: validates, hashes password, creates user row
3. FastAPI: returns UserResponse (no tokens yet)
4. Frontend: auto-login → POST /api/auth/login
5. FastAPI: verifies password, issues access_token (15min) + refresh_token (7d)
6. Frontend: stores tokens, sets user in Zustand store
── LOGIN ─────────────────────────────────────────────────────────────────────
1. User submits login form → POST /api/auth/login
2. FastAPI: verifies email + password hash
3. FastAPI: returns { access_token, refresh_token, token_type: "bearer" }
4. Frontend: stores access_token in Zustand/localStorage
5. Frontend: stores refresh_token in localStorage
6. Frontend: calls GET /api/users/me → populates user in Zustand store
7. Frontend: navigates to intended destination
── AUTHENTICATED REQUEST ──────────────────────────────────────────────────────
1. Component uses useGetPostsQuery() → RTK Query sends GET /api/posts
2. prepareHeaders adds: Authorization: Bearer {access_token}
3. FastAPI: JWTBearer dependency decodes token, extracts user_id
4. FastAPI: returns protected data
── ACCESS TOKEN EXPIRY (silent refresh) ──────────────────────────────────────
1. Component triggers GET /api/posts with expired access_token
2. FastAPI returns 401 Unauthorized
3. RTK Query reauth wrapper intercepts the 401
4. Wrapper: POST /api/auth/refresh with { refresh_token }
5. FastAPI: validates refresh token, issues new access_token + refresh_token
6. Wrapper: stores new tokens, retries original request with new access_token
7. User never knows the token expired
── REFRESH TOKEN EXPIRY (forced logout) ──────────────────────────────────────
1. Refresh attempt returns 401 (refresh token expired or revoked)
2. Wrapper: clears both tokens from storage
3. Wrapper: navigates user to /login
── LOGOUT ────────────────────────────────────────────────────────────────────
1. User clicks "Sign Out"
2. Frontend: POST /api/auth/logout with { refresh_token }
3. FastAPI: adds refresh_token to a revocation list (or deletes it from DB)
4. Frontend: clears tokens from localStorage
5. Frontend: resets Zustand user to null
6. Frontend: clears RTK Query cache (no stale user data)
7. Frontend: navigates to /
── APP STARTUP (page refresh / first visit) ──────────────────────────────────
1. AuthProvider/Zustand init() runs on mount
2. Reads access_token from localStorage
3. If present: GET /api/users/me to validate + populate user
4. If 401: token expired → attempt silent refresh
5. If refresh fails: clear tokens, user is unauthenticated
6. isLoading = false → ProtectedRoute can now make routing decisions
Note: Access tokens are short-lived (15 minutes) and refresh tokens are long-lived (7–30 days). The asymmetry is intentional: if an access token is stolen, it is only usable for 15 minutes. If a refresh token is stolen, the attacker can get new access tokens for 7–30 days — but refresh tokens are sent only once per refresh cycle (to the token endpoint), whereas access tokens are sent with every API request, making them more likely to be intercepted. Revocable refresh tokens (stored in the database) let you invalidate a stolen refresh token immediately.
Tip: Always validate a stored token with the server on app startup (
GET /api/users/me) rather than just checking that a token exists in localStorage. localStorage tokens can be: expired (server will reject them), revoked (server knows but the token hasn’t expired), or tampered with. The server validation ensures the user state is always correct, even after a server-side forced logout or password change that invalidates tokens.Warning: Never store passwords in localStorage or any client-side storage. Tokens are the only credentials that belong in the browser. Even tokens should be treated carefully — access tokens in memory (React state) are safer than in localStorage (accessible to any JavaScript) but are lost on page refresh. The pragmatic choice for most applications is localStorage for both tokens, with short access token lifetimes, HTTPS, and a strict Content Security Policy to mitigate XSS risk.
Token Storage Comparison
| Storage | XSS Risk | CSRF Risk | Persistence | Complexity |
|---|---|---|---|---|
| localStorage (access) | High (any JS can read) | Low | Yes | Low |
| Memory (React state) | None | Low | No (lost on refresh) | Medium |
| httpOnly cookie | None (JS cannot read) | Medium (need CSRF token) | Yes | High |
| sessionStorage | High | Low | Tab-only | Low |