Modern web and mobile applications use JWT (JSON Web Token) authentication to identify users without server-side sessions. But a single JWT creates a fundamental dilemma: make it short-lived (secure but annoying — users get logged out constantly) or long-lived (convenient but dangerous — a stolen token grants extended access). Access tokens and refresh tokens solve this dilemma by splitting authentication into two tokens with different lifespans and purposes.
This guide explains how access tokens and refresh tokens work together, their security properties, storage strategies, rotation mechanisms, and practical implementation with Node.js code examples.
What Is an Access Token?
An access token is a short-lived JWT that proves the user's identity on every API request. It contains the user's ID, role, permissions, and an expiration time. The server verifies the access token's signature on each request — if valid and not expired, the request is authorized.
- Purpose: Authorize individual API requests
- Typical lifespan: 5-30 minutes
- Sent with: Every API request (Authorization: Bearer header)
- Contains: User ID, role, permissions, email, expiration
- Stored: Memory (SPA) or httpOnly cookie (web)
Access Token Payload Example
// Decoded access token payload
{
"sub": "user_123", // Subject (user ID)
"email": "alice@example.com",
"role": "admin",
"permissions": ["read", "write", "delete"],
"iat": 1719216000, // Issued at (Unix timestamp)
"exp": 1719216900 // Expires in 15 minutes
}Because access tokens are sent with every request and verified without a database lookup, they must be short-lived. If stolen, the damage is limited to the remaining lifetime (minutes, not days).
What Is a Refresh Token?
A refresh token is a long-lived credential used exclusively to obtain new access tokens. It is not sent with regular API requests — it is only used when the access token expires. The refresh token is stored securely (httpOnly cookie or secure device storage) and sent to a dedicated token refresh endpoint.
- Purpose: Get new access tokens without re-login
- Typical lifespan: 7-30 days
- Sent to: Only the /auth/refresh endpoint (never regular APIs)
- Contains: Minimal data — usually just a token ID and user reference
- Stored: httpOnly cookie (web) or encrypted storage (mobile)
Why Refresh Tokens Are Needed
Without refresh tokens, you have two bad options:
❌ Short access token, no refresh
User gets logged out every 15 minutes. Terrible experience — user rage-quits your app.
❌ Long access token, no refresh
Token stolen = attacker has access for days/weeks. Cannot revoke. Security nightmare.
✅ Short access token + refresh token
Access token expires in 15 min (limits damage). Refresh token silently gets a new one (seamless UX). Refresh token can be revoked server-side (security control).
Real-world analogy: An access token is like a hotel key card that expires daily. A refresh token is like your reservation confirmation — you show it at the front desk to get a new key card each day without checking in again.
Access Token vs Refresh Token: Side-by-Side Comparison
| Feature | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Obtain new access tokens |
| Expiration | 5-30 minutes | 7-30 days |
| Sent with | Every API request | Only token refresh endpoint |
| Payload size | Large (contains claims) | Small (just ID/reference) |
| Storage (web) | Memory or httpOnly cookie | httpOnly cookie only |
| Storage (mobile) | Secure memory | Encrypted device storage |
| Security if stolen | Limited damage (short life) | Serious damage (long life) |
| Revocable? | No (stateless) | Yes (server stores reference) |
| Server validation | Signature check only (no DB) | Database/Redis lookup required |
| Frequency of use | Every request (hundreds/day) | Every 15 min (few times/day) |
| Should be a JWT? | Always (for stateless verification) | Optional (can be opaque string) |
| Contains user data? | Yes (ID, role, permissions) | Minimal (just token ID) |
Complete Authentication Flow
Here is the full lifecycle of access and refresh tokens working together:
1. LOGIN
User sends email + password → Server returns access token + refresh token
2. API REQUESTS
Client sends access token with every request → Server verifies signature → Authorized
3. ACCESS TOKEN EXPIRES (15 min)
Server returns 401 Unauthorized → Client detects expiration
4. SILENT REFRESH
Client sends refresh token to /auth/refresh → Server issues new access token + new refresh token
5. CONTINUE
Client retries the failed request with new access token → User never noticed
The key UX insight: the user never sees any of this. The token refresh happens automatically in the background. From the user's perspective, they log in once and stay logged in for days or weeks — seamlessly.
Implementation with Node.js
const jwt = require("jsonwebtoken")
const crypto = require("crypto")
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET
// Generate token pair on login
function generateTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
ACCESS_SECRET,
{ expiresIn: "15m" }
)
const refreshToken = jwt.sign(
{ sub: user.id, tokenId: crypto.randomUUID() },
REFRESH_SECRET,
{ expiresIn: "7d" }
)
return { accessToken, refreshToken }
}
// Login endpoint
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body
const user = await db.findUser(email)
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: "Invalid credentials" })
}
const { accessToken, refreshToken } = generateTokens(user)
// Store refresh token in database (for revocation)
await db.storeRefreshToken(refreshToken, user.id)
// Set refresh token as httpOnly cookie
res.cookie("refreshToken", refreshToken, {
httpOnly: true, secure: true, sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, path: "/auth"
})
// Return access token in body (stored in memory by client)
res.json({ accessToken })
})
// Refresh endpoint
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.cookies
if (!refreshToken) return res.status(401).json({ error: "No refresh token" })
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET)
// Check if token exists in database (not revoked)
const stored = await db.findRefreshToken(refreshToken)
if (!stored) return res.status(401).json({ error: "Token revoked" })
// Rotation: invalidate old refresh token
await db.deleteRefreshToken(refreshToken)
// Issue new token pair
const user = await db.findUserById(payload.sub)
const newTokens = generateTokens(user)
await db.storeRefreshToken(newTokens.refreshToken, user.id)
res.cookie("refreshToken", newTokens.refreshToken, {
httpOnly: true, secure: true, sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, path: "/auth"
})
res.json({ accessToken: newTokens.accessToken })
} catch {
return res.status(401).json({ error: "Invalid refresh token" })
}
})
// Logout — revoke refresh token
app.post("/auth/logout", (req, res) => {
const { refreshToken } = req.cookies
if (refreshToken) db.deleteRefreshToken(refreshToken)
res.clearCookie("refreshToken", { path: "/auth" })
res.json({ message: "Logged out" })
})Where Should Tokens Be Stored?
| Storage | XSS Risk | CSRF Risk | Persists? | Best For |
|---|---|---|---|---|
| httpOnly Cookie | None (JS can't read) | Possible (use SameSite) | Yes | Refresh tokens ✅ |
| localStorage | High (JS can read) | None | Yes | ❌ Never for tokens |
| sessionStorage | High (JS can read) | None | Tab only | ❌ Never for tokens |
| Memory (variable) | Low (lost on refresh) | None | No | Access tokens ✅ (SPA) |
| Secure device storage | None (native) | None | Yes | Mobile apps ✅ |
The recommended pattern for web applications: store the refresh token in an httpOnly cookie (sent only to /auth paths) and keep the access token in JavaScript memory. On page reload, the SPA calls /auth/refresh to get a new access token using the cookie.
Refresh Token Rotation
Refresh token rotation is a critical security mechanism where every time a refresh token is used, it is invalidated and a brand new refresh token is issued alongside the new access token. The old refresh token can never be used again.
Why Rotation Matters
Without rotation, a stolen refresh token gives the attacker persistent access for the entire token lifetime (days or weeks). With rotation, theft is detectable:
Scenario: Attacker steals refresh token R1 WITHOUT rotation: Attacker uses R1 → gets access token (works) Attacker uses R1 again → gets access token (still works!) Attacker has unlimited access until R1 expires (days) WITH rotation: Attacker uses R1 → gets access token + new refresh token R2 Real user tries to use R1 → FAILS (R1 was already used) Server detects reuse → REVOKES entire token family Both attacker (R2) and real user are forced to re-login Damage contained
The key insight: token reuse detection. If a refresh token is used twice, someone is either the attacker or the victim — either way, revoking the entire family is the safe response.
Implementation Pattern
Store refresh tokens in a database with a family ID. When rotation detects reuse (a token that has already been used is presented again), delete all tokens belonging to that family:
// Database schema for refresh tokens
// tokens table: { id, token_hash, user_id, family_id, used, expires_at }
async function refreshWithRotation(oldToken) {
const stored = await db.findToken(hash(oldToken))
if (!stored) throw new Error("Token not found")
if (stored.used) {
// REUSE DETECTED — revoke entire family
await db.deleteTokenFamily(stored.family_id)
throw new Error("Token reuse detected — all sessions revoked")
}
// Mark old token as used (not deleted — kept for reuse detection)
await db.markAsUsed(stored.id)
// Issue new token in same family
const newToken = generateRefreshToken()
await db.storeToken({
token_hash: hash(newToken),
user_id: stored.user_id,
family_id: stored.family_id, // Same family
used: false,
expires_at: addDays(7)
})
return newToken
}Security Considerations
Token Theft Scenarios
XSS steals access token from localStorage
Impact: Attacker has full API access for token lifetime
Prevention: Never store tokens in localStorage. Use httpOnly cookies or memory.
Network interception (man-in-the-middle)
Impact: Attacker captures token in transit
Prevention: Always use HTTPS. Set Secure flag on cookies.
Refresh token stolen from cookie
Impact: Attacker can generate unlimited access tokens
Prevention: Use refresh token rotation. Limit refresh token to specific path (/auth).
CSRF forges request with cookie
Impact: Unauthorized actions using victim's session
Prevention: SameSite=Strict on cookies. Use CSRF tokens for state-changing operations.
Expiration Best Practices
| Application Type | Access Token | Refresh Token |
|---|---|---|
| Banking / Healthcare | 5 minutes | 1 day (with re-auth for sensitive ops) |
| SaaS application | 15 minutes | 7 days |
| Social media / Content | 30 minutes | 30 days |
| Mobile app | 15 minutes | 90 days (with biometric re-verification) |
| Machine-to-machine API | 1 hour | Not needed (use client credentials) |
Common Mistakes Developers Make
⚠️ Using long-lived access tokens (24h+) without refresh tokens
Defeats the entire security model. A stolen 24-hour access token gives an attacker full access with no way to revoke it. Always use short access tokens (15 min) + refresh tokens.
⚠️ Storing tokens in localStorage
Any XSS vulnerability exposes all tokens. A single injected script reads localStorage and sends tokens to the attacker's server. Use httpOnly cookies that JavaScript cannot access.
⚠️ Not implementing refresh token rotation
Without rotation, a stolen refresh token grants permanent access until expiration. With rotation, theft is detectable and the entire token family can be revoked immediately.
⚠️ Putting sensitive data in JWT access token payload
JWT payloads are Base64 encoded — anyone can decode them. Never include passwords, credit cards, social security numbers, or internal system details in the payload.
⚠️ Not checking token expiration on the client
Waiting for a 401 response to discover token expiration adds unnecessary latency. Proactively refresh the token 30-60 seconds before expiration for seamless UX.
⚠️ Using the same secret for access and refresh tokens
If the access token secret is compromised, the attacker can forge refresh tokens too. Use separate signing keys for each token type.
Best Practices
Limits the damage window if a token is stolen. The attacker can only use it for minutes, not days.
Issue a new refresh token on every refresh. Detect and revoke on reuse. This is the single most important security measure.
JavaScript cannot read httpOnly cookies, protecting against XSS attacks. Set SameSite=Strict and Secure flags.
Different secrets for access and refresh tokens. If one is compromised, the other remains secure.
Don't trust a token just because the signature is valid. Check expiration, issuer, and audience on every request.
Delete the refresh token from the server store and clear the cookie. The access token will expire naturally within minutes.
Tokens sent over HTTP can be intercepted by anyone on the network. Enforce HTTPS in production without exceptions.
Client-side: check if the access token expires within 60 seconds and refresh preemptively. Avoids the 401 → refresh → retry cycle.
Set the cookie path to /auth so the refresh token is only sent to authentication endpoints — not with every API request.
Frequently Asked Questions
What is the difference between an access token and a refresh token?
Can refresh tokens expire?
Should refresh tokens be JWTs?
Where should refresh tokens be stored?
Can I use only access tokens without refresh tokens?
What is refresh token rotation?
What happens if a refresh token is stolen?
How do I implement logout with JWT tokens?
Related Articles & Tools
Conclusion
Access tokens and refresh tokens work as a team to solve the fundamental tension between security (short-lived credentials) and user experience (staying logged in). Access tokens handle the high-frequency work of authorizing every API request — they are short-lived, stateless, and cannot be revoked. Refresh tokens handle the low-frequency work of maintaining the user's session — they are long-lived, stored server-side, and fully revocable.
The essential implementation checklist: use 15-minute access tokens, 7-day refresh tokens with rotation, httpOnly cookie storage for web, separate signing secrets, proper expiration validation, and HTTPS everywhere. With these fundamentals in place, you have a secure, scalable authentication system that provides seamless user experience while maintaining strict security controls.
