JWT Access Token vs Refresh Token — Complete Guide with Code Examples
I inherited a codebase where the "access token" had a 24-hour lifetime and the "refresh token" was... also a JWT with a 24-hour lifetime. They were the same token, just named differently. There was no refresh flow. The client would "refresh" by sending the same token back, and the server would extend its expiration.
That is not how the two-token system works. That is just a long-lived access token with extra steps.
The access token / refresh token pattern is one of the most misunderstood concepts in JWT authentication. Developers implement it without understanding why two tokens exist, what each one protects, and how they work together. The result is systems that have the complexity of a two-token architecture without the security benefits.
This guide covers the complete picture: the purpose of each token, the full exchange flow, rotation requirements, storage strategies, and production implementations.
The Core Idea in 30 Seconds
The two-token system solves a fundamental tension:
- Short-lived tokens are more secure but require frequent re-authentication.
- Long-lived tokens are more convenient but increase the damage if stolen.
The solution: use both.
| Token | Lifetime | Purpose | Where It Goes |
|---|---|---|---|
| Access Token | 5–15 minutes | Authorizes API requests | Sent with every request (Authorization header) |
| Refresh Token | Days to months | Issues new access tokens | Sent only to the refresh endpoint |
The access token goes everywhere — HTTP headers, URL parameters, WebSocket connections. It is the most exposed credential. Making it short-lived limits the damage if it leaks.
The refresh token stays protected. It is sent rarely (only to refresh the access token) and stored securely. If the access token is compromised, the attacker has at most 15 minutes of access. They cannot get a new access token without the refresh token.
The Token Exchange Flow
Here is the complete flow from login to token refresh:
1. User logs in with email/password (or OAuth provider)
2. Server validates credentials
3. Server generates:
- Access token (JWT, 15min, signed)
- Refresh token (opaque, 30 days, hashed in DB)
4. Server returns both to client
5. Client stores:
- Access token in memory (or localStorage)
- Refresh token in httpOnly cookie (or secure storage)
6. Client uses access token for all API requests
7. When access token expires (401 response):
- Client calls POST /auth/refresh with refresh token
- Server validates refresh token (hash match, not revoked, not expired)
- Server rotates refresh token (optional but recommended)
- Server returns new access token + new refresh token
8. Client retries the original request with new access token
Client Server
| |
|--- POST /login -------->| (email + password)
|<-- {access, refresh} ---|
| |
|--- GET /api/data ------>| (Authorization: Bearer access_token)
|<-- 200 OK -------------|
| |
|--- GET /api/data ------>| (access token expired)
|<-- 401 Unauthorized ---|
| |
|--- POST /auth/refresh ->| (refresh token)
|<-- {new_access, new_refresh}|
| |
|--- GET /api/data ------>| (Authorization: Bearer new_access_token)
|<-- 200 OK -------------|
| |
Implementation (Node.js)
Token Issuance
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
class TokenService {
constructor(db, redis) {
this.db = db;
this.redis = redis;
}
// --- Access Token ---
issueAccessToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role,
jti: uuidv4(), // Unique token ID for revocation
type: 'access', // Explicit token type
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
}
verifyAccessToken(token) {
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'],
});
}
// --- Refresh Token ---
generateRefreshToken() {
return crypto.randomBytes(40).toString('hex');
}
hashRefreshToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
async issueRefreshToken(userId) {
const token = this.generateRefreshToken();
const hash = this.hashRefreshToken(token);
const family = uuidv4();
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await this.db.query(
`INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
VALUES ($1, $2, $3, $4)`,
[userId, hash, family, expiresAt]
);
return { token, expiresAt };
}
async rotateRefreshToken(oldToken) {
const oldHash = this.hashRefreshToken(oldToken);
return await this.db.transaction(async (trx) => {
// Lock and find the old token
const rows = await trx.query(
`SELECT id, user_id, family, revoked, expires_at
FROM refresh_tokens
WHERE token_hash = $1
FOR UPDATE`,
[oldHash]
);
if (rows.length === 0) {
throw new TokenError('REFRESH_TOKEN_INVALID', 'Refresh token not found');
}
const stored = rows[0];
if (stored.expires_at < new Date()) {
throw new TokenError('REFRESH_TOKEN_EXPIRED', 'Refresh token has expired');
}
if (stored.revoked) {
// Reuse detected — revoke entire token family
await trx.query(
`UPDATE refresh_tokens
SET revoked = TRUE, revoked_reason = 'reuse_detected'
WHERE family = $1 AND revoked = FALSE`,
[stored.family]
);
throw new TokenError(
'REFRESH_TOKEN_REUSE',
'Token reuse detected — family revoked'
);
}
// Revoke old token
await trx.query(
`UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'rotated'
WHERE id = $1`,
[stored.id]
);
// Issue new token
const newToken = this.generateRefreshToken();
const newHash = this.hashRefreshToken(newToken);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await trx.query(
`INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
VALUES ($1, $2, $3, $4)`,
[stored.user_id, newHash, stored.family, expiresAt]
);
return { token: newToken, userId: stored.user_id, family: stored.family };
});
}
async revokeRefreshToken(token) {
const hash = this.hashRefreshToken(token);
await this.db.query(
`UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'explicit'
WHERE token_hash = $1 AND revoked = FALSE`,
[hash]
);
}
async revokeAllUserRefreshTokens(userId) {
await this.db.query(
`UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'logout_all'
WHERE user_id = $1 AND revoked = FALSE AND expires_at > NOW()`,
[userId]
);
}
}
class TokenError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}
Login and Refresh Endpoints
const express = require('express');
const router = express.Router();
const tokenService = new TokenService(db, redis);
// Login
router.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Issue both tokens
const accessToken = tokenService.issueAccessToken(user);
const { token: refreshToken, expiresAt } = await tokenService.issueRefreshToken(user.id);
// Set refresh token as httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
res.json({
accessToken,
expiresIn: 900,
tokenType: 'Bearer',
});
});
// Refresh
router.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
const { token: newRefreshToken, userId, family } =
await tokenService.rotateRefreshToken(refreshToken);
// Issue new access token
const user = await getUserById(userId);
const accessToken = tokenService.issueAccessToken(user);
// Update refresh cookie
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
// Detect reuse: if this token was already rotated, the family is revoked
// and the client should re-authenticate
res.json({
accessToken,
expiresIn: 900,
tokenType: 'Bearer',
});
} catch (err) {
if (err instanceof TokenError) {
// Clear the stale cookie
res.clearCookie('refresh_token', { path: '/api/auth' });
if (err.code === 'REFRESH_TOKEN_REUSE') {
// Token reuse detected — potential token theft
// Log security event and force re-authentication
console.error(`Token reuse detected for family ${family}`);
return res.status(401).json({
error: 'SESSION_COMPROMISED',
message: 'Session has been compromised. Please log in again.',
});
}
return res.status(401).json({ error: err.code, message: err.message });
}
console.error('Refresh error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout
router.post('/auth/logout', async (req, res) => {
const refreshToken = req.cookies?.refresh_token;
if (refreshToken) {
await tokenService.revokeRefreshToken(refreshToken);
}
res.clearCookie('refresh_token', { path: '/api/auth' });
res.json({ message: 'Logged out' });
});
Implementation (Python / FastAPI)
import hashlib
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional, Tuple
import jwt
from fastapi import FastAPI, HTTPException, Request, Response
from pydantic import BaseModel
app = FastAPI()
ACCESS_SECRET = "your-access-secret"
REFRESH_SECRET = "your-refresh-secret"
ACCESS_EXPIRY = 15 # minutes
REFRESH_EXPIRY = 30 # days
class TokenService:
def __init__(self, db):
self.db = db
def issue_access_token(self, user_id: int, role: str) -> str:
return jwt.encode(
{
"sub": user_id,
"role": role,
"jti": str(uuid.uuid4()),
"type": "access",
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(minutes=ACCESS_EXPIRY),
},
ACCESS_SECRET,
algorithm="HS256",
)
def verify_access_token(self, token: str) -> dict:
try:
return jwt.decode(token, ACCESS_SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Access token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid access token")
def generate_refresh_token(self) -> str:
return secrets.token_hex(40)
def hash_token(self, token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def issue_refresh_token(self, user_id: int) -> Tuple[str, datetime]:
token = self.generate_refresh_token()
token_hash = self.hash_token(token)
family = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(days=REFRESH_EXPIRY)
await self.db.execute(
"""INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
VALUES ($1, $2, $3, $4)""",
user_id, token_hash, family, expires_at,
)
return token, expires_at
async def rotate_refresh_token(self, old_token: str) -> Tuple[str, int]:
old_hash = self.hash_token(old_token)
async with self.db.transaction():
row = await self.db.fetchrow(
"""SELECT id, user_id, family, revoked, expires_at
FROM refresh_tokens
WHERE token_hash = $1
FOR UPDATE""",
old_hash,
)
if not row:
raise HTTPException(status_code=401, detail="Invalid refresh token")
if row["expires_at"] < datetime.utcnow():
raise HTTPException(status_code=401, detail="Refresh token expired")
if row["revoked"]:
# Reuse detected
await self.db.execute(
"""UPDATE refresh_tokens
SET revoked = TRUE, revoked_reason = 'reuse_detected'
WHERE family = $1 AND revoked = FALSE""",
row["family"],
)
raise HTTPException(
status_code=401,
detail="Session compromised — token reuse detected",
)
# Revoke old token
await self.db.execute(
"""UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'rotated'
WHERE id = $1""",
row["id"],
)
# Issue new token
new_token = self.generate_refresh_token()
new_hash = self.hash_token(new_token)
expires_at = datetime.utcnow() + timedelta(days=REFRESH_EXPIRY)
await self.db.execute(
"""INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
VALUES ($1, $2, $3, $4)""",
row["user_id"], new_hash, row["family"], expires_at,
)
return new_token, row["user_id"]
# FastAPI endpoints
from fastapi import Depends, Cookie
token_service = TokenService(db)
@app.post("/api/auth/login")
async def login(email: str, password: str, response: Response):
user = await authenticate_user(email, password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = token_service.issue_access_token(user["id"], user["role"])
refresh_token, expires_at = await token_service.issue_refresh_token(user["id"])
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="strict",
path="/api/auth",
max_age=30 * 24 * 60 * 60,
)
return {"access_token": access_token, "expires_in": ACCESS_EXPIRY * 60}
@app.post("/api/auth/refresh")
async def refresh(
response: Response,
refresh_token: Optional[str] = Cookie(None),
):
if not refresh_token:
raise HTTPException(status_code=401, detail="Refresh token required")
try:
new_refresh_token, user_id = await token_service.rotate_refresh_token(refresh_token)
except HTTPException:
response.delete_cookie("refresh_token", path="/api/auth")
raise
user = await get_user_by_id(user_id)
access_token = token_service.issue_access_token(user["id"], user["role"])
response.set_cookie(
key="refresh_token",
value=new_refresh_token,
httponly=True,
secure=True,
samesite="strict",
path="/api/auth",
max_age=30 * 24 * 60 * 60,
)
return {"access_token": access_token, "expires_in": ACCESS_EXPIRY * 60}
@app.post("/api/auth/logout")
async def logout(
response: Response,
refresh_token: Optional[str] = Cookie(None),
):
if refresh_token:
await token_service.revoke_refresh_token(refresh_token)
response.delete_cookie("refresh_token", path="/api/auth")
return {"message": "Logged out"}
Client-Side Token Management
The client needs to handle token refresh transparently. Here is a complete fetch wrapper:
class AuthenticatedClient {
constructor(baseURL) {
this.baseURL = baseURL;
this.accessToken = null;
this.refreshPromise = null;
}
async init() {
// Try to restore session on page load
try {
const { accessToken } = await this.refresh();
this.accessToken = accessToken;
} catch {
this.accessToken = null;
}
}
async login(email, password) {
const res = await fetch(`${this.baseURL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
const data = await res.json();
this.accessToken = data.accessToken;
return data;
}
async refresh() {
// Deduplicate concurrent refresh calls
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = fetch(`${this.baseURL}/api/auth/refresh`, {
method: 'POST',
credentials: 'include', // Send httpOnly cookie
}).then(async (res) => {
this.refreshPromise = null;
if (!res.ok) throw new Error('Refresh failed');
return res.json();
}).catch((err) => {
this.refreshPromise = null;
throw err;
});
return this.refreshPromise;
}
async request(path, options = {}) {
const url = `${this.baseURL}${path}`;
const headers = { ...options.headers };
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
let res = await fetch(url, { ...options, headers });
// If 401, try refreshing
if (res.status === 401 && this.accessToken) {
try {
const { accessToken } = await this.refresh();
this.accessToken = accessToken;
headers['Authorization'] = `Bearer ${accessToken}`;
res = await fetch(url, { ...options, headers });
} catch {
this.accessToken = null;
// Redirect to login
window.location.href = '/login';
throw new Error('Session expired');
}
}
return res;
}
async logout() {
this.accessToken = null;
await fetch(`${this.baseURL}/api/auth/logout`, {
method: 'POST',
credentials: 'include',
});
}
}
// Usage
const api = new AuthenticatedClient('https://api.example.com');
await api.init(); // Restore session on page load
// All authenticated requests go through api.request()
const res = await api.request('/api/user');
const user = await res.json();
Refresh Token Rotation
The most important security feature of refresh tokens is rotation. Every time a refresh token is used, the server issues a new one and revokes the old one.
Why Rotation Matters
Without rotation, a stolen refresh token is valid for its entire lifetime (possibly 30 days). The attacker has a month of access.
With rotation, if the attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail because the old token was already revoked. Both parties detect the issue:
- The user: gets logged out unexpectedly. They contact support.
- The server: detects reuse when the old token is presented again. It can revoke the entire token family and flag the account for security review.
Reuse Detection
When rotation detects that a revoked token is being used again, this is strong evidence of token theft. The server should:
- Revoke every token in the same
family(all rotated descendants) - Force the user to re-authenticate
- Log the security event
- Optionally notify the user via email
// In the rotation method
if (stored.revoked) {
// This token was already rotated — someone is using an old token
// Revoke the entire family
await trx.query(
`UPDATE refresh_tokens
SET revoked = TRUE, revoked_reason = 'reuse_detected'
WHERE family = $1 AND revoked = FALSE`,
[stored.family]
);
// Notify security team
await notifySecurityEvent('TOKEN_REUSE', {
userId: stored.user_id,
family: stored.family,
timestamp: new Date(),
});
throw new TokenError(
'REFRESH_TOKEN_REUSE',
'Potential token theft detected'
);
}
Database Schema for Refresh Tokens
CREATE TABLE refresh_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash CHAR(64) NOT NULL,
family CHAR(36) NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
revoked_reason VARCHAR(50) DEFAULT NULL,
revoked_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
UNIQUE KEY idx_token_hash (token_hash),
INDEX idx_user_id (user_id),
INDEX idx_family (family),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Key design decisions:
- token_hash stores SHA-256 hash, not the token itself. A database breach does not leak tokens.
- token_hash is UNIQUE — prevents accidental duplicate insertions and makes lookups fast.
- family groups related tokens together. All rotated tokens share the same family UUID.
- revoked is soft-delete. Expired tokens are cleaned up by a background job.
Common Mistakes
Mistake 1: Access Token Same as Refresh Token
If both tokens are JWTs and both have the same lifetime, you do not have a two-token system. You have one token that you are calling two different things.
- Access tokens: JWTs, 5–15 minutes, stateless verification
- Refresh tokens: Opaque random strings, days/weeks, database verification
Mistake 2: No Rotation
Issuing a refresh token and never rotating it means the refresh token is a permanent credential. If it leaks, the attacker has indefinite access. Rotate on every use.
Mistake 3: Storing Access Token in the Same Place as Refresh Token
If both tokens are in localStorage, a single XSS vulnerability leaks both. Store the access token in memory, the refresh token in an httpOnly cookie. See Is It Safe to Store JWT in localStorage? for the full analysis.
Mistake 4: Long Access Token Lifetime
Access tokens should live 5–15 minutes. Anything longer increases the damage from token leakage. If you think your users cannot handle frequent refreshes, your refresh token mechanism should be invisible — not your access token should be longer. For a complete implementation walkthrough including rotation and revocation, see JWT Refresh Token Implementation.
Mistake 5: No Reuse Detection
Rotation without reuse detection is a missed opportunity. If you rotate but do not check for reuse, you lose the intrusion detection benefit of the two-token system.
Mistake 6: Refresh Token in Plaintext in Database
-- BAD: Storing the token directly
CREATE TABLE refresh_tokens (
token TEXT NOT NULL -- plaintext token leak
);
-- GOOD: Storing a hash
CREATE TABLE refresh_tokens (
token_hash CHAR(64) NOT NULL -- SHA-256 hash
);
FAQ
What is the difference between an access token and a refresh token?
An access token is short-lived (5–15 minutes) and authorizes API requests. It is sent with every request and verified statelessly. A refresh token is long-lived (days to months) and used only to obtain new access tokens. It is stored securely and rotated on each use.
Why use two tokens instead of one long-lived access token?
Two tokens limit the damage of token theft. If an access token is stolen, it is valid for minutes. If a refresh token is stolen, rotation and reuse detection limit the damage and alert you to the theft. A single long-lived token gives the attacker persistent access.
Can the refresh token be a JWT?
It can be, but it should not be. Refresh tokens should be opaque, cryptographically random strings. JWTs encode claims that become stale, and they cannot be revoked without additional infrastructure (deny list). Opaque tokens force a database lookup, which enables revocation and rotation.
How long should access tokens live?
5–15 minutes is the standard. Access tokens are the most exposed credential — they are sent with every request. Short lifetimes limit the damage window if one leaks.
How long should refresh tokens live?
30 days is common for consumer applications. 7–14 days for enterprise or high-security applications. The tradeoff is convenience vs. security. Support "remember this device" to extend the lifetime.
Should refresh tokens be rotated?
Yes. Always. Rotation limits the window of a compromised refresh token and enables reuse detection. Without rotation, a stolen refresh token is valid for its entire lifetime.
What happens when a token is reused?
Revoke the entire token family (all tokens sharing the same family ID). Force the user to re-authenticate. Log the security event. Notify the user and/or security team. This is your intrusion detection system for token theft.
Do I need a database for refresh tokens?
Yes. Refresh tokens must be revocable, rotatable, and have bounded lifetimes. A database (with hashed tokens) provides all of these properties. This is one reason refresh tokens are not JWTs — they require server-side state.
Summary
The access token / refresh token pattern is the foundation of secure JWT authentication. Access tokens are short-lived JWTs that authorize requests. Refresh tokens are long-lived opaque tokens that issue new access tokens.
The security of the system depends on four things:
- Short-lived access tokens — limit the damage of token leakage
- Refresh token rotation — detect and prevent token theft
- Reuse detection — alert you when theft has occurred
- Secure storage — access token in memory, refresh token in httpOnly cookie
When you need to decode and inspect access tokens during development or debugging, the JWT Decoder shows the full payload, expiration, and claims — helping you verify that your token issuance and refresh logic is working correctly.