In this guide
You built login with JWT. The user logs in, gets a token, and your frontend attaches it to every request. It works — until you ask the obvious question: how long should the token live?
Make it live for 30 days and a stolen token gives an attacker a month of access. Make it live for 15 minutes and your users get logged out constantly. The industry answer is two tokens with different jobs — and a rotation pattern that limits the damage of theft. This guide explains it properly.
The two-token model
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Short (5–15 minutes) | Long (days to weeks) |
| Where it goes | Every API request (Authorization header) | Only to the token-refresh endpoint |
| What it proves | "This request is authenticated" | "Issue me a new access token" |
| Stored where | Memory (preferred) or httpOnly cookie | httpOnly, Secure, SameSite cookie |
The logic: the access token is exposed constantly (every request), so it must die quickly. The refresh token is used rarely (once per 15 minutes), so it can live longer — but it needs stronger protection because it is the more powerful credential.
What a JWT actually contains
A JWT is three Base64 parts joined by dots: header.payload.signature. The payload is just JSON claims:
{
"sub": "user_42",
"iat": 1727085600,
"exp": 1727086500,
"type": "access"
}
sub is the subject (who), iat issued-at, exp expiry, and a type claim distinguishing access from refresh tokens. The signature lets your server verify the token was not tampered with — without a database lookup, which is the whole performance point of JWTs.
Note: JWT payloads are Base64-encoded, not encrypted. Anyone holding the token can read the claims. Never put sensitive data (passwords, internal IDs you want hidden) in a JWT payload.
The rotation pattern, step by step
"Rotation" means every refresh consumes the old refresh token and issues a brand-new pair. Here is the flow:
- Login — user provides credentials; server returns an access token (15 min) and a refresh token (7 days) in an httpOnly cookie.
- Normal requests — frontend sends the access token; server verifies the signature and expiry. No database hit.
- Access token expires — frontend calls
POST /auth/refresh(the cookie goes automatically); server validates the refresh token. - Rotation — server issues a NEW access token AND a NEW refresh token, and invalidates the old refresh token.
- Repeat — the client now holds the new pair; the old refresh token can never be used again.
Why rotate instead of reusing one long-lived refresh token? Theft detection. If an attacker steals the refresh token and uses it, they get a new pair — and the legitimate user's next refresh attempt fails because their (now old) token was already consumed. That failure is a signal: someone else is using this session. The server can then revoke the whole token family and force a re-login.
Refresh token reuse detection
This is the key security property. Implementation sketch:
- Server keeps a small record per refresh token: token ID, user ID, and whether it has been used.
- On refresh: if the presented token was already consumed → treat as suspected theft → revoke all tokens for that user → require fresh login.
- If valid and unused → mark it consumed, issue a new pair.
You do need some server-side storage for refresh tokens (or at least their IDs) — this is the accepted trade-off. Access tokens stay fully stateless; refresh tokens get a lightweight allowlist.
Where to store tokens (the decision that matters most)
| Storage | XSS risk | CSRF risk | Verdict |
|---|---|---|---|
| localStorage | High — any injected script reads it | None | Avoid for tokens |
| httpOnly cookie | Low — JS cannot read it | Needs SameSite + anti-CSRF | Recommended for refresh tokens |
| In-memory (JS variable) | Low — gone on page reload | None | Good for access tokens in SPAs |
The widely recommended student-friendly setup:
- Access token in memory — a JS variable. Survives navigation, dies on reload (then you silently refresh on app load). Immune to both XSS theft-by-reading and CSRF.
- Refresh token in httpOnly, Secure, SameSite=Strict cookie — JS cannot touch it, the browser sends it only to your domain, and only over HTTPS.
Note: SameSite cookies handle most CSRF, but defense in depth still suggests anti-CSRF tokens for state-changing endpoints if your threat model needs it. The OWASP Top 10 guide covers the attack side.
The silent-refresh UX pattern
Users should never see "session expired" if their refresh token is valid. The standard SPA pattern:
- An HTTP client wrapper (e.g. an Axios interceptor) catches 401 responses.
- On 401, it calls
/auth/refresh, retries the original request with the new access token, all invisible to the user. - If refresh also fails → redirect to login.
- Guard against parallel refreshes: if five requests 401 at once, only one refresh call should fire; the others wait for it.
Logout done right
Logout is not just "delete tokens on the client." A complete logout:
- Client discards the access token from memory.
- Client calls
POST /auth/logout. - Server invalidates the refresh token (removes it from the allowlist) and clears the cookie.
- For "log out everywhere," invalidate all refresh tokens for that user.
Without step 3, a stolen refresh token keeps working after the user "logged out" — a real and common bug.
Sliding sessions vs absolute limits
Rotation gives you sliding expiration: every refresh extends the session, so an active user stays logged in indefinitely. That is usually what you want — but consider two refinements:
- Absolute session lifetime. Even with rotation, cap the total session (e.g. 30 days), after which the user must re-enter credentials. This bounds the damage of a stolen refresh token that the legitimate user never notices.
- Sensitivity-based lifetimes. A banking app might use 5-minute access tokens and 12-hour refresh tokens; a forum can live with 1-hour access and 30-day refresh. Match token lifetimes to what is at stake, not to a blog post's defaults.
- "Remember me" as a real choice. Unchecked: short refresh lifetime (a day) tied to the browser session. Checked: long refresh lifetime (weeks) in a persistent cookie. Two code paths, meaningfully different security postures — implement both deliberately instead of one accidental middle ground.
Common mistakes
- One long-lived JWT for everything. A 30-day access token in localStorage is the worst of all worlds: constantly exposed, rarely expiring, readable by any XSS payload.
- No rotation. Reusing a single refresh token forever means theft is invisible — attacker and user share the session indefinitely.
- Storing refresh tokens in localStorage. The most stolen credential in the least protected place.
- Forgetting reuse detection. Rotation without tracking consumed tokens gives you new tokens but no theft signal.
- Refresh endpoint without rate limiting. It is a prime brute-force target; rate-limit it like a login endpoint. See Rate Limiting APIs: Strategies Explained.
- Putting the kitchen sink in JWT claims. Keep payloads small — the token travels on every request.
Quick checklist
- Access tokens: 5–15 minute expiry,
type: accessclaim - Refresh tokens: httpOnly + Secure + SameSite cookie, server-side allowlist
- Rotation on every refresh, with reuse detection that revokes the family
- Silent refresh in the frontend HTTP layer with single-flight refresh
- Logout invalidates server-side, not just client-side
- Refresh and login endpoints rate-limited
Where to go from here
- New to JWT structure and claims? Start with JWT Authentication Explained.
- Adding social login on top? The OAuth2 flow guide shows where refresh tokens fit in the code exchange.
- Attack-side perspective: OWASP Top 10 for student web apps.
- Serving everything over HTTPS: how to get a free SSL certificate.
- More backend topics in the Web Development branch hub.