NodeJSAccess & Refresh Tokens

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 / HttpOnly cookie

HttpOnly cookie / secure store

If stolen

Tiny exposure window

Serious — but revocable

Server state

None (stateless JWT)

Tracked so it can be revoked

Access tokens are used everywhere and expire fast; refresh tokens are rare and revocable
The access token does the day-to-day work and is deliberately short-lived, so a leaked one is useless within minutes. The refresh token is presented **only** to a single `/refresh` endpoint, lives long, and — crucially — is **tracked server-side** so you can revoke it on logout or theft. This restores the clean revocation that pure stateless JWTs lack, while keeping per-request verification stateless. It's the pragmatic middle ground between [sessions](/nodejs/sessions) and bare [JWTs](/nodejs/jwt).
The flow

Text
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 again
Issuing both at login

JS
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
}
Store refresh tokens HASHED — a leaked DB shouldn't hand out valid tokens
A refresh token is a long-lived credential, so treat it like a password: store only a **hash** of it, never the raw value. If your database leaks, the attacker gets hashes they can't use, not working refresh tokens. Scope the cookie tightly (`HttpOnly`, `Secure`, `SameSite=strict`, `path=/auth/refresh`) so it's only ever sent to the one endpoint that needs it — minimizing where it can leak from.
The refresh endpoint with rotation

JS
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 })
})
Rotate refresh tokens: each use issues a new one and invalidates the old
With **rotation**, every call to `/refresh` consumes the presented refresh token and issues a fresh one. A refresh token is therefore single-use. This shrinks the window any leaked refresh token is valid and — combined with reuse detection below — lets you spot theft. Without rotation, a stolen refresh token quietly works for its entire lifetime.
Detecting refresh token theft
A reused (already-rotated) refresh token signals theft — revoke the whole family
With rotation, a valid refresh token should only ever be used **once**. If a token that's already been rotated out shows up again, two parties hold it — the legitimate user and a thief. This is your theft signal: when you detect reuse of an invalidated refresh token, **revoke every refresh token in that user's chain** (force re-login). Track tokens as a family/chain so one detected reuse invalidates all descendants. This turns rotation from a nicety into an active intrusion detector.
Logout and revocation

JS
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).
})
Logout kills the refresh token instantly; the access token dies on its own shortly after
Because access tokens are stateless, logout can't instantly invalidate one already in the wild — but since it expires in minutes, that's an acceptable, *bounded* window. What logout *does* do is delete the refresh token so no **new** access tokens can be minted. If you need truly instant access-token revocation (e.g. on a security incident), add a short-lived denylist keyed by token `jti` — accepting the bit of server state that buys you. For most apps, short expiry + refresh revocation is enough.
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.

Next
Skip hand-rolling strategies with a pluggable auth framework: [Passport.js](/nodejs/passport).