Access & Refresh Tokens
Short-lived JWTs are secure but annoying — if an access token expires in 15 minutes, must users log in every 15 minutes? No. The standard solution splits authentication into two tokens: a short-lived access token used on every request, and a long-lived refresh token whose only job is to mint new access tokens. This gives you the best of both worlds — a tiny window of risk if an access token leaks, plus long sessions without re-login. This page covers the two-token model, secure storage, rotation, and detecting token theft.
Why two tokens
Access token | Refresh token | |
|---|---|---|
Lifetime | Short (5–15 min) | Long (days–weeks) |
Sent | On every API request | Only to the refresh endpoint |
Stored | Memory / |
|
If stolen | Tiny exposure window | Serious — but revocable |
Server state | None (stateless JWT) | Tracked so it can be revoked |
The flow
LOGIN → server issues access (15m) + refresh (7d, stored in DB)
REQUESTS → client sends access token; server verifies statelessly
EXPIRES → access token rejected with 401
REFRESH → client POSTs refresh token → server validates it against the DB
→ issues a NEW access token (and a new refresh — see rotation)
LOGOUT → server deletes the refresh token record → can't refresh againIssuing both at login
import jwt from 'jwt'
import crypto from 'node:crypto'
function issueTokens(user, res) {
const accessToken = jwt.sign({ sub: user.id, role: user.role }, ACCESS_SECRET,
{ expiresIn: '15m' })
// Refresh token: an opaque random string we STORE (hashed) and can revoke:
const refreshToken = crypto.randomBytes(32).toString('hex')
db.refreshTokens.create({
userId: user.id,
tokenHash: sha256(refreshToken), // store a HASH, like a password
expiresAt: Date.now() + 7 * 864e5,
})
// Send refresh in an HttpOnly cookie scoped to the refresh path:
res.cookie('refresh', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
maxAge: 7 * 864e5,
})
return accessToken // access token goes in the JSON body / memory
}The refresh endpoint with rotation
app.post('/auth/refresh', async (req, res) => {
const presented = req.cookies.refresh
if (!presented) return res.status(401).json({ error: 'No refresh token' })
const record = await db.refreshTokens.findByHash(sha256(presented))
if (!record || record.expiresAt < Date.now()) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
// ROTATION: invalidate the used token, issue a brand-new pair:
await db.refreshTokens.delete(record.id)
const user = await db.users.findById(record.userId)
const accessToken = issueTokens(user, res) // sets a new refresh cookie too
res.json({ accessToken })
})Detecting refresh token theft
Logout and revocation
app.post('/auth/logout', async (req, res) => {
const presented = req.cookies.refresh
if (presented) await db.refreshTokens.deleteByHash(sha256(presented))
res.clearCookie('refresh', { path: '/auth/refresh' })
res.json({ ok: true })
// Note: any already-issued ACCESS token still works until it expires (~minutes).
})Rules
Separate secrets for access and refresh tokens (or token types) so one can’t mint the other.
Refresh tokens are opaque + stored hashed; access tokens are stateless JWTs.
Rotate on every refresh and detect reuse to catch theft.
Scope the refresh cookie to the refresh path with
HttpOnly/Secure/SameSite.Short access expiry (5–15 min) bounds the damage of a leaked access token.
Revoke on logout, password change, and suspected compromise.