50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
authMay 23, 2026·8 min read

Refresh Tokens Explained — Why Your JWT Setup Is Probably Wrong

Short-lived access tokens + refresh tokens sounds simple. Most implementations get it subtly wrong in ways that hurt security or UX.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Refresh Tokens Explained — Why Your JWT Setup Is Probably Wrong

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.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders