We've reviewed a lot of auth implementations over the years — our own included. The pattern we see most often: someone sets up JWTs, gives them a 7-day expiry "for convenience", and calls it done. That's not an access token anymore. That's a session cookie wearing a trench coat pretending to be stateless auth. You've got the worst of both worlds.
The correct setup — short-lived access tokens paired with long-lived refresh tokens — is well-documented but poorly understood. People copy the theory but mess up the implementation details. Let's fix that.
What's Actually Going On With These Two Tokens
Here's the mental model: your access token is like a hotel key card. It gets you through doors, but it expires after checkout. Your refresh token is like the front desk — you go there when the key card stops working and get a new one issued. The front desk checks your actual identity. The key card doesn't.
Access tokens are short-lived (15 minutes is reasonable, 1 hour is common, anything beyond that starts to hurt you). They're sent with every request, often in the Authorization header. They're stateless — the server validates the signature and trusts the claims without hitting a database. That's the performance win.
Refresh tokens are long-lived (days, weeks, sometimes months). They're stored securely and only sent to one specific endpoint — your token refresh endpoint. When the access token expires, the client hits that endpoint with the refresh token and gets a new access token back. Crucially, the server checks whether the refresh token is still valid in a database. That's where you get revocation capability.
The reason this system exists: access tokens can't be revoked without going stateful, but refresh tokens can. If someone's account is compromised, you delete their refresh tokens. Their access token lives for at most 15 more minutes. That's your blast radius.
Mistake #1: Storing Refresh Tokens in localStorage
We see this constantly. The access token in memory (or localStorage), the refresh token also in localStorage. The thinking is "it's just easier to work with". The problem is XSS. Any injected script — from a compromised npm package, a third-party widget, anything — can read localStorage and steal both tokens. An attacker with your refresh token can mint new access tokens indefinitely until you notice.
The right call is storing your refresh token in an HttpOnly, Secure, SameSite=Strict cookie. JavaScript can't touch it. It gets sent automatically with requests to your token endpoint only. Here's what that looks like when you issue tokens server-side in a Next.js route handler:
// app/api/auth/refresh/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { verifyRefreshToken, issueAccessToken, rotateRefreshToken } from '@/lib/tokens'
export async function POST() {
const cookieStore = cookies()
const refreshToken = cookieStore.get('refresh_token')?.value
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
}
const payload = await verifyRefreshToken(refreshToken)
if (!payload) {
return NextResponse.json({ error: 'Invalid or expired refresh token' }, { status: 401 })
}
// Rotate the refresh token (more on this below)
const newRefreshToken = await rotateRefreshToken(payload.userId, refreshToken)
const newAccessToken = await issueAccessToken(payload.userId)
const response = NextResponse.json({ accessToken: newAccessToken })
response.cookies.set('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/api/auth', // Only sent to auth endpoints
})
return response
}Notice the path is set to '/api/auth' — that means the cookie only gets sent when the browser hits that path. No leaking your refresh token to every API call.
Mistake #2: Not Rotating Refresh Tokens
This is the one that really stings when you get it wrong. Refresh token rotation means: every time a refresh token is used, you invalidate it and issue a new one. One use per token.
Why? Because if a refresh token gets stolen, and you don't rotate, the attacker just... keeps using it. Forever. With rotation, the legitimate user and the attacker are both holding the same token. As soon as the legitimate user uses it, the attacker's copy is dead. As soon as the attacker uses it first, you can detect that the old token was reused and flag the session as compromised.
Here's a basic implementation using Drizzle and Postgres:
// lib/tokens.ts
import { db } from '@/lib/db'
import { refreshTokens } from '@/lib/db/schema'
import { eq, and } from 'drizzle-orm'
import { SignJWT, jwtVerify } from 'jose'
import { randomBytes } from 'crypto'
const REFRESH_SECRET = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET!)
const ACCESS_SECRET = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET!)
export async function issueAccessToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m')
.setIssuedAt()
.sign(ACCESS_SECRET)
}
export async function rotateRefreshToken(
userId: string,
oldToken: string
): Promise<string> {
// Check old token exists and isn't already used
const existing = await db.query.refreshTokens.findFirst({
where: and(
eq(refreshTokens.userId, userId),
eq(refreshTokens.token, oldToken),
eq(refreshTokens.used, false)
)
})
if (!existing) {
// Token reuse detected — invalidate ALL tokens for this user
await db.delete(refreshTokens).where(eq(refreshTokens.userId, userId))
throw new Error('Refresh token reuse detected. All sessions invalidated.')
}
// Mark old token as used
await db
.update(refreshTokens)
.set({ used: true })
.where(eq(refreshTokens.id, existing.id))
// Issue new token
const newToken = randomBytes(64).toString('hex')
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
await db.insert(refreshTokens).values({
userId,
token: newToken,
expiresAt,
used: false,
})
return newToken
}
export async function verifyRefreshToken(token: string) {
const record = await db.query.refreshTokens.findFirst({
where: and(
eq(refreshTokens.token, token),
eq(refreshTokens.used, false)
)
})
if (!record || record.expiresAt < new Date()) return null
return { userId: record.userId }
}The reuse detection is the critical part. If someone uses an already-used token, that's a sign an attacker intercepted it. The correct response is scorched earth — nuke all sessions for that user and force re-login. Harsh, but it's the right call.
Mistake #3: Refreshing Tokens Too Late (or Too Eagerly)
Client-side token refresh has a timing problem. If you only try to refresh after a request fails with 401, you get a jarring user experience — the request fails, then you refresh, then you retry. That's potentially three round trips for one user action.
The better pattern: decode the access token on the client (it's not a secret, just don't trust it without verification), check the exp claim, and refresh proactively when there's less than 2-3 minutes left. Here's how we handle this in a fetch wrapper:
// lib/auth-fetch.ts
let accessToken: string | null = null
let refreshPromise: Promise<string> | null = null
function getTokenExpiry(token: string): number {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.exp * 1000 // Convert to ms
}
function isTokenExpiringSoon(token: string): boolean {
const expiry = getTokenExpiry(token)
const twoMinutes = 2 * 60 * 1000
return Date.now() > expiry - twoMinutes
}
async function refreshAccessToken(): Promise<string> {
// Prevent multiple simultaneous refresh calls
if (refreshPromise) return refreshPromise
refreshPromise = fetch('/api/auth/refresh', { method: 'POST' })
.then(res => {
if (!res.ok) throw new Error('Refresh failed')
return res.json()
})
.then(data => {
accessToken = data.accessToken
return accessToken as string
})
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
export async function authFetch(url: string, options: RequestInit = {}) {
if (!accessToken || isTokenExpiringSoon(accessToken)) {
accessToken = await refreshAccessToken()
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
})
// Still handle 401 as fallback
if (response.status === 401) {
accessToken = await refreshAccessToken()
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
})
}
return response
}The deduplication with refreshPromise is important. Without it, if three concurrent requests all see an expired token, you'd fire three refresh requests simultaneously. Race conditions in auth are not fun to debug at 2am — we speak from experience.
Mistake #4: Keeping Refresh Tokens in the Database Forever
If you're storing refresh tokens in a database (and you should be, for revocation), you need to clean them up. A user who's been active for two years will have... a lot of rows. Especially if you're issuing new tokens on every page load or every login across multiple devices.
- Delete expired tokens on a schedule (a cron job running nightly is fine)
- Delete all tokens for a user on password change or logout
- Limit active refresh tokens per user — 5-10 concurrent devices is reasonable
- Mark tokens with device metadata (user agent, IP) so users can see active sessions
- If you're on Postgres, index on (user_id, used, expires_at) or your cleanup queries will hurt
The Setup People Actually Want: Putting It All Together
Here's the full flow when it's done right:
- User logs in → server verifies credentials → issues 15-minute access token (returned in response body) and 30-day refresh token (set as HttpOnly cookie)
- Client stores access token in memory only — not localStorage, not sessionStorage
- Every API request uses the access token in the Authorization header
- A fetch wrapper proactively refreshes the access token 2 minutes before expiry
- Refresh call hits /api/auth/refresh — the HttpOnly cookie is sent automatically, no JS needed
- Server validates the refresh token against the database, rotates it, returns a new access token
- On logout → DELETE the refresh token from the database, clear the cookie
One thing people trip over: what happens when the user closes the tab? Your in-memory access token is gone. When they come back, you need to immediately hit the refresh endpoint to get a new access token using the cookie. This should happen in your app's initialization, before any protected requests go out. A silent refresh on app load.
If your auth library handles all this for you — great. But if you're rolling your own or debugging why users keep getting logged out randomly, this is almost always the culprit: a missing silent refresh on app load.
When You Don't Need Any of This
Refresh tokens add complexity. There are real cases where that complexity isn't worth it. If you're building a server-rendered Next.js app and using server-side sessions with something like Auth.js (formerly NextAuth), you get session management handled for you. Cookies are managed server-side, you don't have XSS risks with token storage, and revocation is straightforward. The JWT/refresh token dance is mostly relevant when you're talking to external APIs from a SPA, or when you need truly stateless API servers.
The peal.dev Next.js templates ship with Auth.js set up by default for exactly this reason — it's the pragmatic choice for most web apps. But when you need custom token-based auth — say, you're building an API that mobile apps also consume — you want the setup described above, and you want it done properly from the start rather than retrofitted after a security incident.
The Practical Takeaway
If you're auditing your current JWT setup, check these things in order:
- Is your access token expiry under 1 hour? If not, you're not really getting stateless auth benefits anyway — just shorten it and add a proper refresh flow
- Is your refresh token in an HttpOnly cookie? If it's in localStorage, fix this first — it's your biggest risk
- Are you rotating refresh tokens on every use? If not, stolen tokens have indefinite lifetime
- Do you have reuse detection? If someone replays an old refresh token, are you nuking their sessions?
- Are you cleaning up old tokens from the database? A table with millions of expired rows will eventually hurt you
None of this is particularly hard to implement. It's just fiddly enough that people skip steps when they're in a hurry. The steps you skip are exactly what attackers look for. Take the two extra hours, do it right, and you won't have to think about it again.
