Access Token vs Refresh Token
Why one login gives you two tokens: access tokens are short-lived and sent everywhere (small blast radius if stolen), refresh tokens are long-lived and server-tracked (enabling real logout). Together they escape the single-token security-vs-UX trade-off.
A single token forces a choice you can't win: make it long-lived and a stolen copy compromises your users for weeks; make it short-lived and they re-login every few minutes. The two-token pattern escapes the trap entirely.
- Mental model: Access token is your movie ticket (show it on every request, expires quickly, losing one is a small problem); refresh token is the ticket booth's password (stored securely, shown only to the booth, can be revoked, but must be rotated on every use).
The Single-Token Trap
When you authenticate, you need something that proves "I am Alice" on every subsequent request. A naive system hands you one token and calls it done.
But now you face an impossible trade-off:
- Long-lived token (weeks/months): Your session survives server restarts and network hiccups. But if that token leaks via XSS, a log file, or a compromised reverse proxy, an attacker owns Alice's account for the full duration. Weeks of damage. No kill switch.
- Short-lived token (minutes): A leak is contained — the attacker gets maybe five minutes before the token dies and they're locked out. But Alice also gets locked out in five minutes. Every request after that triggers a re-authentication dance. Constant friction.
You cannot have security and a smooth UX with one token.
Enter: Two Tokens
The insight is to split the job. One token handles the "prove who you are" role. The other handles the "keep me logged in" role. Together, they let you optimize both.
The Access Token: Short & Everywhere
This is the token you send with every API request.
Characteristics:
- Lifetime: 5–15 minutes (or shorter)
- Transport: Included in the
Authorization: Bearerheader on every API call - Format: Usually a stateless JWT
- Verification: The server decodes the JWT signature without a database lookup; no session state needed
- Storage: In memory (or a standard HTTP header)
Security model: If an access token leaks, the damage window is narrow. An attacker can impersonate Alice only until the token expires — often in minutes. In the meantime, the legitimate user can still use their refresh token to get a fresh access token and continue their session uninterrupted.
Tradeoff accepted: Yes, a leaked access token is a problem. But the blast radius is small because the token dies fast.
// On every API request, the Authorization header carries the access token
GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI...
// The server verifies the signature instantly; no DB lookup needed
const decoded = jwt.verify(token, SECRET_KEY);
if (decoded.exp < Date.now() / 1000) {
return 401; // Expired
}
return getUserData(decoded.sub);
The Refresh Token: Long & Secure
This token has one job: to mint fresh access tokens. It never rides on regular API calls.
Characteristics:
- Lifetime: Days or weeks
- Transport: Sent only to the authorization server's
/refreshendpoint, usually as anhttpOnlycookie or in a secure header - Never sent to: Your user API, payment API, or any other service
- Verification: The server does a database lookup to check if the refresh token is still valid (not revoked)
- Storage: Securely stored on the server; never exposed to JavaScript (if using
httpOnlycookies)
How it works:
// When the access token expires, the client calls the refresh endpoint
POST /auth/refresh
Content-Type: application/json
Cookie: refreshToken=xyz...
// The auth server checks: is this refresh token valid and not revoked?
const refreshRecord = await db.query(
'SELECT * FROM refresh_tokens WHERE token = ?',
[refreshToken]
);
if (!refreshRecord || refreshRecord.revoked) {
return 401; // Token was revoked; user must re-login
}
// Issue a fresh access token
const newAccessToken = jwt.sign(
{ sub: refreshRecord.user_id, exp: Date.now() / 1000 + 600 },
SECRET_KEY
);
return { accessToken: newAccessToken };
The leverage:
Because the refresh token is server-tracked, it can be revoked. That's your logout button. Call DELETE FROM refresh_tokens WHERE user_id = ? and Alice is logged out — everywhere, instantly. No waiting for stateless JWTs to expire.
The Critical Detail: Refresh Token Rotation
Here's the catch: a long-lived token is dangerous if stolen. So the two-token pattern includes a must-have mitigation: rotate the refresh token on every use.
One-time-use refresh tokens:
When the client exchanges a refresh token for a new access token, the server does two things:
- Issues a new access token.
- Issues a new refresh token and invalidates the old one.
Now, if an attacker ever steals a refresh token, they can use it once — but that use creates a new refresh token on the server, under a different ID or with tracking metadata. The legitimate user's next refresh call will fail because their old token is dead. The attacker can't stay logged in.
// Pseudocode for one-time-use refresh
const { refreshToken } = req.cookies;
const oldRecord = await db.query(
'SELECT * FROM refresh_tokens WHERE token = ? AND revoked = FALSE',
[refreshToken]
);
if (!oldRecord) {
// Token already used or doesn't exist — possible theft attempt
await db.query(
'UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = ?',
[oldRecord.user_id]
);
return 401; // "You were logged out everywhere due to suspicious activity"
}
// Revoke the old token
await db.query('UPDATE refresh_tokens SET revoked = TRUE WHERE id = ?', [oldRecord.id]);
// Issue a new refresh token
const newRefreshToken = crypto.randomBytes(32).toString('hex');
await db.query(
'INSERT INTO refresh_tokens (user_id, token, revoked) VALUES (?, ?, FALSE)',
[oldRecord.user_id, newRefreshToken]
);
// Return the new access token and new refresh token
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
return {
accessToken: jwt.sign(
{ sub: oldRecord.user_id, exp: Date.now() / 1000 + 600 },
SECRET_KEY
)
};
The upside: a stolen refresh token dies on first use. The downside: you must track every refresh token in a database (refresh tokens cannot be stateless).
Comparison: One Token vs. Two Tokens
| Dimension | Single Token | Access + Refresh (Two Token) |
|---|---|---|
| User Session Length | If long (weeks): leaks are catastrophic. If short (minutes): users re-login constantly. | Access token short (minutes); refresh token long (weeks). Users stay logged in, but blast radius is small. |
| Verification Cost | One lookup or one signature check per request. | Access token: signature check only (stateless). Refresh token: database lookup (only at refresh time, not every request). |
| Logout Capability | None (or wait for token to expire). | Immediate: revoke the refresh token in the database. |
| Theft Impact | All-or-nothing: either damage lasts weeks, or users are constantly bounced. | Access token theft: minutes of exposure. Refresh token theft (with rotation): one refresh cycle before rotation invalidates it. |
| Statefulness | Can be stateless (JWT) or stateful (session store). | Access token: stateless. Refresh token: must be stateful (tracked in a database for revocation and rotation). |
| Storage Complexity | One place; straightforward. | Access token: in memory or header (simple). Refresh token: `httpOnly` cookie or secure server-side storage (more careful). |
For LLM Inference Workloads
If you're running inference endpoints (e.g., exposing an LLM API), the two-token pattern maps cleanly to latency concerns:
- Access token → Time-to-first-token (TTFT). Every inference request authenticates with a short-lived access token. Since verification is stateless (just a JWT signature check), it adds microseconds, not milliseconds. No database round-trip on the hot path.
- Refresh token → Out-of-band auth management. The client refreshes access tokens between requests or at token expiry, not during inference. This move the auth latency off the critical path.
For high-throughput inference, stateless access tokens are essential: they let you verify requests without serializing through a single auth database.
When to Use Each
Reach for the two-token pattern when you need both a smooth user experience (no constant re-login) and a real logout lever (revoke sessions instantly). This covers nearly all web and mobile apps.
Reach for a single long-lived token only when your threat model accepts weeks of exposure (e.g., internal APIs behind a corporate firewall where theft is less likely) and you're willing to trade UX friction for simplicity.
Reach for a single short-lived token only when you have a synchronous re-authentication mechanism (e.g., an embedded app where the user is always at a keyboard or a WebSocket that can silently re-auth) and can afford the latency cost.
In practice: use two tokens. The pattern is well-understood, widely supported (OAuth 2.0, OpenID Connect), and solves all three problems at once.
Watch the 90-second reel for a visual breakdown of this trade-off.