Why You Need Two Tokens
A single long-lived JWT is convenient but insecure: if it leaks, you have no way to revoke it before its expiry. A single short-lived JWT is secure but painful: users get logged out every 15 minutes. The two-token pattern lets you have both — the access token is short so leaks are contained, the refresh token is long-lived but revokable and rarely transmitted.
Think of it like a hotel: the access token is your room card (works everywhere for a short stay), and the refresh token is your reservation confirmation (proves you have a room and can get a new card, but you show it once at check-in, not every time you open the door).
Complete Node.js Example
import express from 'express';
import { SignJWT, jwtVerify } from 'jose';
import { randomBytes } from 'crypto';
const app = express();
app.use(express.json());
app.use(require('cookie-parser')());
const ACCESS_SECRET = new TextEncoder().encode(process.env.ACCESS_SECRET);
const REFRESH_SECRET = new TextEncoder().encode(process.env.REFRESH_SECRET);
// In-memory store; use Redis/Postgres in production
const refreshTokenStore = new Map(); // jti -> { userId, revoked, used }
async function issueAccessToken(userId) {
return new SignJWT({ userId, typ: 'access' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(ACCESS_SECRET);
}
async function issueRefreshToken(userId) {
const jti = randomBytes(16).toString('hex');
refreshTokenStore.set(jti, { userId, revoked: false, used: false });
return new SignJWT({ userId, jti, typ: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(REFRESH_SECRET);
}
// POST /login
app.post('/login', async (req, res) => {
const userId = await checkPassword(req.body.email, req.body.password);
if (!userId) return res.status(401).end();
const accessToken = await issueAccessToken(userId);
const refreshToken = await issueRefreshToken(userId);
res.cookie('refreshToken', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
// POST /refresh — with rotation
app.post('/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).end();
let payload;
try {
({ payload } = await jwtVerify(token, REFRESH_SECRET, {
algorithms: ['HS256'],
}));
if (payload.typ !== 'refresh') throw new Error('wrong type');
} catch {
return res.status(401).end();
}
const record = refreshTokenStore.get(payload.jti);
if (!record || record.revoked) return res.status(401).end();
// Theft detection — same refresh token used twice
if (record.used) {
// Revoke all refresh tokens for this user
for (const [jti, r] of refreshTokenStore) {
if (r.userId === payload.userId) r.revoked = true;
}
return res.status(401).json({ error: 'token reuse detected — logout everywhere' });
}
record.used = true;
// Rotate — issue new pair
const accessToken = await issueAccessToken(payload.userId);
const refreshToken = await issueRefreshToken(payload.userId);
res.cookie('refreshToken', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
// POST /logout
app.post('/logout', async (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
try {
const { payload } = await jwtVerify(token, REFRESH_SECRET, { algorithms: ['HS256'] });
const record = refreshTokenStore.get(payload.jti);
if (record) record.revoked = true;
} catch {}
}
res.clearCookie('refreshToken');
res.status(204).end();
});
// Auth middleware for protected routes
app.use('/api', async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).end();
try {
const { payload } = await jwtVerify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
if (payload.typ !== 'access') throw new Error();
req.userId = payload.userId;
next();
} catch {
res.status(401).json({ error: 'expired' });
}
});Complete Python (FastAPI) Example
from fastapi import FastAPI, HTTPException, Depends, Response, Cookie
from datetime import datetime, timedelta
import jwt, secrets, os
app = FastAPI()
ACCESS_SECRET = os.environ["ACCESS_SECRET"]
REFRESH_SECRET = os.environ["REFRESH_SECRET"]
refresh_store = {} # jti -> {"user_id", "revoked", "used"} — use Redis in prod
def issue_access(user_id: str) -> str:
return jwt.encode({
"user_id": user_id, "typ": "access",
"exp": datetime.utcnow() + timedelta(minutes=15),
}, ACCESS_SECRET, algorithm="HS256")
def issue_refresh(user_id: str) -> str:
jti = secrets.token_hex(16)
refresh_store[jti] = {"user_id": user_id, "revoked": False, "used": False}
return jwt.encode({
"user_id": user_id, "typ": "refresh", "jti": jti,
"exp": datetime.utcnow() + timedelta(days=7),
}, REFRESH_SECRET, algorithm="HS256")
@app.post("/login")
def login(email: str, password: str, resp: Response):
user_id = check_password(email, password)
if not user_id:
raise HTTPException(401)
access = issue_access(user_id)
refresh = issue_refresh(user_id)
resp.set_cookie("refresh_token", refresh, httponly=True,
secure=True, samesite="strict", max_age=7*86400)
return {"access_token": access}
@app.post("/refresh")
def refresh(resp: Response, refresh_token: str = Cookie(None)):
if not refresh_token:
raise HTTPException(401)
try:
payload = jwt.decode(refresh_token, REFRESH_SECRET,
algorithms=["HS256"])
if payload["typ"] != "refresh":
raise HTTPException(401)
except jwt.PyJWTError:
raise HTTPException(401)
rec = refresh_store.get(payload["jti"])
if not rec or rec["revoked"]:
raise HTTPException(401)
if rec["used"]:
# theft — revoke all
for r in refresh_store.values():
if r["user_id"] == payload["user_id"]: r["revoked"] = True
raise HTTPException(401, detail="token reuse detected")
rec["used"] = True
access = issue_access(payload["user_id"])
refresh = issue_refresh(payload["user_id"])
resp.set_cookie("refresh_token", refresh, httponly=True,
secure=True, samesite="strict", max_age=7*86400)
return {"access_token": access}Client-Side Refresh Handling
// A single flight refresh — prevents multiple simultaneous refresh calls
let refreshPromise = null;
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: 'Bearer ' + accessToken },
});
if (res.status === 401) {
// Coalesce concurrent refresh calls
refreshPromise ||= fetch('/refresh', { method: 'POST', credentials: 'include' })
.then(r => { if (!r.ok) throw new Error('refresh failed'); return r.json(); })
.then(({ accessToken: newToken }) => { accessToken = newToken; })
.finally(() => { refreshPromise = null; });
try {
await refreshPromise;
// Retry once with new access token
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: 'Bearer ' + accessToken },
});
} catch {
// Refresh failed — redirect to login
window.location.href = '/login';
}
}
return res;
}Design Decisions Worth Locking In
- Different secrets for access vs refresh. Compromise of one does not compromise both.
typclaim on every token. Reject an access token at /refresh and vice versa — this prevents cross-endpoint token confusion.- Rotation always on. Every /refresh call must issue a new refresh token and invalidate the old one.
- Refresh token in HttpOnly cookie. Never expose it to JavaScript. Never send it in localStorage.
- Access token in memory only. Losing it on page reload is a feature — the refresh flow re-issues it silently.
- Sliding session on refresh. Each rotation extends the effective session to another 7 days. If a user is active they never re-authenticate; if they walk away they auto-log-out after refresh expiry.
- Theft detection. If the same refresh token is used twice (a stolen copy + the legitimate copy), revoke ALL refresh tokens for that user immediately.
Related JWT Tools
- JWT Expiration Checker — check if a token is still valid
- JWT Generator Online — mint test tokens for your refresh flow
- Verify JWT Signature — verify signatures locally
- JWT Decoder Online — inspect any token
- JWT HS256 Decoder — the default access-token algorithm