Logo
Overview
One Token Isn't Enough: How Access + Refresh Token Auth Actually Works

One Token Isn't Enough: How Access + Refresh Token Auth Actually Works

September 28, 2026
14 min read

The Problem: One Token Forces a Bad Tradeoff

The first version of auth in almost any backend looks the same. Someone sits down one afternoon, signs a JWT on login, and calls it done:

const token = jwt.sign({ sub: user.id }, SECRET, { expiresIn: '30d' })
res.json({ token })

The frontend stores it in localStorage, attaches it to every request, and everything works. Nobody is filing a bug about it.

Then the product grows up, and the questions start:

"A user's laptop got stolen. How do we log them out?"
"We changed a user's role. Why do they still have admin access?"
"Someone leaked a token in a log file. Now what?"

The answer to all three is the same: you can’t. A stateless JWT is valid until it expires, and the server holds no record of it. Nothing on your side says “this token is no longer welcome.”

So you shorten the expiry:

expiresIn: "30d" → attacker has a month
expiresIn: "15m" → user logs in again every 15 minutes

That’s the trap. With a single token, you’re picking between a security problem and a UX problem.

Note (The real issue is the lifetime, not the token)

The token that travels with every request should live for minutes. But the session should feel like it lasts weeks. One token can’t be both, so we use two.


Part 1: Two Tokens, Two Jobs

Instead of one token doing everything, split the responsibilities:

ACCESS TOKEN REFRESH TOKEN
"Let me call the API" "Let me get a new access token"
↓ ↓
short-lived (5-15 min) long-lived (7-30 days)
↓ ↓
sent on EVERY request sent to ONE endpoint
↓ ↓
verified without a DB hit verified against the DB

The trick is the exposure profile. The access token is everywhere: every request, every log line, every proxy. But it’s only valuable for a few minutes. The refresh token is valuable for weeks, but it’s sent rarely and only to /auth/refresh, so there’s far less opportunity for it to leak.

Here’s the whole lifecycle in one picture:

Client Server
│ │
│── POST /auth/login ───────────────────▶│ verify password
│◀── access (15m) + refresh (7d) ────────│ store hash(refresh)
│ │
│── GET /api/me [Bearer access] ───────▶│ verify JWT signature
│◀── 200 ────────────────────────────────│
│ │
│ ... 15 minutes later ... │
│ │
│── GET /api/me [Bearer access] ───────▶│
│◀── 401 (expired) ──────────────────────│
│ │
│── POST /auth/refresh [refresh] ───────▶│ look up hash, rotate
│◀── NEW access + NEW refresh ───────────│
│ │
│── GET /api/me [Bearer new access] ───▶│
│◀── 200 ────────────────────────────────│

The user never sees the 401. The client catches it, refreshes silently, and retries. From their side, they’ve been logged in for a week.


Part 2: Design Decisions That Matter

There are a few choices here that look small and end up deciding whether your setup is actually secure.

JWT for access, opaque string for refresh

These two tokens have opposite requirements, so they should be different kinds of token.

ACCESS TOKEN → JWT
• verified by signature alone (no DB lookup on every request)
• carries { sub, exp } and optionally { role }
• can't be revoked, but it expires in minutes anyway
REFRESH TOKEN → random opaque string
• just 32 random bytes, means nothing on its own
• looked up in the DB, so it CAN be revoked
• no reason to make it self-describing

Making the refresh token a JWT is a common mistake. The whole point of the refresh token is that the server can say “no, not this one.” If you’re checking the database anyway, the JWT’s self-contained claims give you nothing, and you’ve just made the token bigger and more parseable.

Note (Where the role claim fits)

Embedding role in the access token means most reads need zero database access. The cost: a demoted admin keeps admin rights until their current access token expires. That’s the 15-minute blast radius you’re trading for speed — which is exactly why the TTL is 15 minutes and not 30 days.

Hash the refresh token before storing it

A refresh token in your database is basically a password. If your DB leaks and the tokens are stored in plaintext, every active session is compromised.

Client holds: a3f9...c81e (the real token)
DB holds: sha256(a3f9...c81e)
Lookup: hash the incoming token, compare hashes

You don’t need bcrypt or argon2 here. Those are slow on purpose to protect low-entropy human passwords. A refresh token is 256 bits of randomness, so brute-forcing it is already impossible, and plain SHA-256 is fine.

Where each token lives on the client

Web:
access token → in memory (a JS variable)
refresh token → httpOnly + Secure + SameSite cookie,
scoped to path=/auth/refresh
Mobile:
both → Keychain (iOS) / Keystore (Android)

An httpOnly cookie can’t be read by JavaScript, so an XSS attack can’t steal it. And because the cookie is scoped to /auth/refresh, the browser won’t even send it on other requests. The access token lives in memory: XSS can use it while the page is open, but it’s gone on refresh and worthless in minutes.

Warning (Don't put either token in localStorage)

Any script on your page can read it, which means one XSS bug hands over the keys. This is the single most common auth mistake in tutorials. Note the asymmetry: the access token in memory is not safe from XSS, it’s just short-lived. The cookie is what actually raises the bar.

Note (SameSite: strict vs lax)

SameSite=Strict is the safest default and works fine when /auth/refresh is called by your own page. It bites in one place: if a user returns to your app from an external redirect (an OAuth callback, a magic link), a strict cookie isn’t sent on that cross-site navigation, and the first refresh fails. Lax is the pragmatic choice there — it’s still not sent on cross-site POSTs, which is the case that matters.


Part 3: Rotation and Reuse Detection

This is the part that turns “two tokens” into a genuinely secure design.

Without rotation, a refresh token is valid for its whole 7-30 days. If someone steals it, they can quietly mint access tokens for the entire period, and you’d never know.

Rotation fixes this: every time a refresh token is used, it’s consumed and replaced.

login → refresh token RT1
refresh → RT1 used → issue RT2
refresh → RT2 used → issue RT3
refresh → RT3 used → issue RT4

Each token works exactly once. Now watch what happens when one is stolen:

Legit user Attacker Server
│ │ │
│ steals RT2 ───────────▶│ │
│ │ │
│── refresh with RT2 ─────┼──────────────────────▶│ RT2 valid → mark used
│◀── RT3 ─────────────────┼───────────────────────│ issue RT3
│ │ │
│ │── refresh with RT2 ──▶│ RT2 ALREADY USED
│ │ │ ⚠ theft detected
│ │ │ revoke entire family
│◀── 401 (RT3 is dead too)┼───────────────────────│
│ forced to log in │◀── 401 ───────────────│

Whoever uses the old token second gets caught. The server can’t tell who is the attacker, so it does the safe thing: revoke the whole token family and force a fresh login.

To do this, every refresh token belongs to a family, the chain of tokens that descended from a single login:

family_id = F1
RT1 (used) → RT2 (used) → RT3 (active)

Reuse of any used token in the family kills the family. That’s the entire mechanism: one extra column and one extra check.

Note (Rotation has a real-world wrinkle)

Two browser tabs can fire a refresh at the same moment, and the second one looks exactly like reuse. Production setups usually allow a short grace window (a few seconds) for the just-rotated token, or coordinate refreshes on the client so only one request is ever in flight. Otherwise your own users will log each other out.


Part 4: Implementation in TypeScript

Here’s the whole thing with Express, jose for the JWT, and pg for storage. First, the table:

CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id UUID NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX refresh_tokens_family_idx ON refresh_tokens (family_id);

Token helpers:

import { SignJWT, jwtVerify } from 'jose'
import { randomBytes, createHash, randomUUID } from 'node:crypto'
const ACCESS_SECRET = new TextEncoder().encode(process.env.ACCESS_SECRET!)
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const signAccessToken = (userId: string) =>
new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime('15m')
.sign(ACCESS_SECRET)
export const verifyAccessToken = async (token: string) => {
const { payload } = await jwtVerify(token, ACCESS_SECRET, {
algorithms: ['HS256'], // always pin the algorithm
})
return payload
}
const newRefreshToken = () => randomBytes(32).toString('base64url')
const hash = (t: string) => createHash('sha256').update(t).digest('hex')

Issuing a refresh token, and setting it as a cookie:

async function issueRefreshToken(userId: string, familyId = randomUUID()) {
const token = newRefreshToken()
await db.query(
`INSERT INTO refresh_tokens (user_id, family_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
[userId, familyId, hash(token), new Date(Date.now() + REFRESH_TTL_MS)],
)
return token
}
const setRefreshCookie = (res: Response, token: string) =>
res.cookie('refresh_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: REFRESH_TTL_MS,
})

Login:

app.post('/auth/login', async (req, res) => {
const { email, password } = loginSchema.parse(req.body) // zod
const user = await findUserByEmail(email)
if (!user || !(await argon2.verify(user.passwordHash, password))) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const accessToken = await signAccessToken(user.id)
const refreshToken = await issueRefreshToken(user.id)
setRefreshCookie(res, refreshToken)
res.json({ accessToken })
})

Refresh, where all the interesting logic lives:

app.post('/auth/refresh', async (req, res) => {
const incoming = req.cookies.refresh_token
if (!incoming) return res.sendStatus(401)
// Atomically consume the token: only succeeds if it's unused,
// unrevoked, and unexpired. Two concurrent requests can't both win.
const { rows } = await db.query(
`UPDATE refresh_tokens
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND revoked_at IS NULL
AND expires_at > now()
RETURNING user_id, family_id`,
[hash(incoming)],
)
if (rows.length === 0) {
// Either it never existed, expired, or it was ALREADY USED.
// Reuse of a known token means possible theft: burn the family.
const known = await db.query(
`SELECT family_id FROM refresh_tokens WHERE token_hash = $1`,
[hash(incoming)],
)
if (known.rows[0]) {
await db.query(
`UPDATE refresh_tokens SET revoked_at = now()
WHERE family_id = $1 AND revoked_at IS NULL`,
[known.rows[0].family_id],
)
}
res.clearCookie('refresh_token', { path: '/auth/refresh' })
return res.sendStatus(401)
}
const { user_id, family_id } = rows[0]
const accessToken = await signAccessToken(user_id)
const refreshToken = await issueRefreshToken(user_id, family_id) // same family
setRefreshCookie(res, refreshToken)
res.json({ accessToken })
})

The UPDATE ... WHERE used_at IS NULL ... RETURNING is doing the heavy lifting. It checks and consumes the token in a single atomic statement, so there’s no window between “is it valid?” and “mark it used” for a race condition to slip through.

Note (Why a single atomic UPDATE matters here)

The naive version reads the row, checks used_at IS NULL in application code, then writes used_at = now(). Two concurrent requests both read the same unused row and both succeed — rotation silently stops working, and the reuse tripwire never fires. UPDATE ... WHERE used_at IS NULL has the database do the check and the write as one indivisible operation, so exactly one caller can ever win.

Logout:

app.post('/auth/logout', async (req, res) => {
const incoming = req.cookies.refresh_token
if (incoming) {
await db.query(
`UPDATE refresh_tokens SET revoked_at = now()
WHERE family_id = (SELECT family_id FROM refresh_tokens WHERE token_hash = $1)`,
[hash(incoming)],
)
}
res.clearCookie('refresh_token', { path: '/auth/refresh' })
res.sendStatus(204)
})

Note what logout revokes: the whole family, not just the presented token. The user’s other sessions — a phone, a laptop — stay alive on their own families.

And the auth middleware protecting your routes:

export const requireAuth: RequestHandler = async (req, res, next) => {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) return res.sendStatus(401)
try {
const payload = await verifyAccessToken(header.slice(7))
req.userId = payload.sub!
next()
} catch {
res.sendStatus(401) // client will hit /auth/refresh and retry
}
}

Part 5: Common Mistakes and a Security Checklist

Most auth breaches come from small decisions, not clever attacks. The usual suspects:

✗ Tokens in localStorage → XSS reads them
✗ Refresh token is also a JWT → can't meaningfully revoke
✗ Refresh token stored in plaintext → DB leak = every session stolen
✗ No rotation → a stolen token lives for weeks
✗ Not pinning the JWT algorithm → alg confusion / "alg: none" attacks
✗ Weak or hardcoded signing secret → attacker forges their own tokens
✗ Stuffing lots of data in the JWT → it's readable by anyone (base64, not encrypted)
✗ No rate limiting on login/refresh → brute force and credential stuffing

A quick checklist before you ship:

□ Access token ≤ 15 minutes
□ Refresh token is random, opaque, and stored hashed
□ Rotation on every refresh + family revocation on reuse
□ Refresh cookie: httpOnly, Secure, SameSite, path-scoped
□ Access token kept in memory, not localStorage
□ Algorithm pinned on verify; secret is long and from env
□ Rate limiting on /auth/login and /auth/refresh
□ Logout actually revokes server-side
□ "Log out everywhere" and password-change both revoke all families
□ iss / aud checked on verify, if you ever run more than one API

Part 6: When You Shouldn’t Use This

This setup is the right default, but it isn’t free. You’re now running a token table, rotation logic, and a refresh flow on the client. That’s real complexity, so here’s when to skip it.

Server-side sessions are often simpler. If you have one backend serving one web frontend, a session ID in an httpOnly cookie, backed by Redis or Postgres, gives you instant revocation with far less machinery:

Session: JWT + Refresh:
cookie → session id cookie → refresh token
server looks up session + access token to manage
revoke = delete the row + rotation + families
one moving part several moving parts

Access-token-only is fine for:

  • Internal tools and prototypes where the stakes are low
  • Server-to-server calls, where the client can just re-authenticate with a client secret
  • Very short-lived sessions where re-login isn’t painful

And if you find yourself rebuilding OAuth, stop and consider a managed provider (Auth0, Clerk, Supabase Auth, Keycloak) instead. Auth is one of the few areas where “build it yourself” is rarely the pragmatic call.

Note (The rule I'd actually defend)

One web frontend and one backend? Use sessions. Multiple clients, mobile apps, or several services verifying tokens independently? That’s where JWT + rotating refresh tokens earns its complexity.


Part 7: The Whole System in One Trace

Let’s run one user through the whole thing:

1. Login → password verified with argon2
2. Issue → access JWT (15m) + refresh RT1 (7d)
DB: hash(RT1), family F1, unused
3. Request → Bearer access → signature check → 200
4. 15 min pass → access expires → 401
5. Refresh → RT1 presented → atomically marked used
DB: hash(RT2), family F1, unused
client gets new access + RT2
6. Attacker → replays old RT1 → already used
⚠ family F1 revoked → RT2 dead
7. Everyone → 401 → forced re-login → new family F2

The mental model comes down to three separate jobs:

┌──────────────────────────────┐
│ PROVING WHO YOU ARE │
│ password / OAuth / passkey │
│ → login │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ CALLING THE API │
│ short-lived JWT │
│ → stateless, fast │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ STAYING LOGGED IN │
│ rotating refresh token │
│ → stateful, revocable│
└──────────────────────────────┘

Access tokens give you speed and statelessness. Refresh tokens give you control and revocation. Rotation gives you a tripwire for theft. Each piece exists to fix a specific weakness in the one before it, and that’s the real reason the design works.

For most products that need multi-client auth, this lands in a sweet spot: good UX, limited damage when something leaks, and a real logout button. If you don’t need that, sessions are still a perfectly respectable answer.


Further Reading