Refresh Token Rotation With Next-Auth V5 || Managing Tokens With A Custom Backend
Implement refresh token rotation in Next.js with Next-Auth v5 and a custom backend — access/refresh JWT fundamentals, /login and /refresh endpoints, JWT and session callbacks, expiry checks, and seamless token renewal.
Introduction
When your auth lives on a custom backend — not inside Next-Auth itself — you issue your own access and refresh tokens. Next-Auth v5 becomes the session manager: it stores tokens in an encrypted JWT cookie, rotates them before expiry, and exposes them to your app through callbacks.
This guide covers refresh token rotation with Next-Auth v5 — managing tokens from an external API you control.
Refresh Token Rotation with Next-Auth V5
│
┌───────┬───────┼───────┬──────────┬──────────┐
│ │ │ │ │ │
Token Backend Next-Auth Rotation Implementation
Fundamentals Endpoints Core Logic ToolsWhat you will build:
| Branch | What it covers |
|---|---|
| 1. Token fundamentals | Access token, refresh token, JWT structure |
| 2. Backend API endpoints | /register, /login, /users, /refresh |
| 3. Next-Auth core concepts | Credentials provider, authorize, JWT & session callbacks |
| 4. Token rotation logic | Expiry check, refresh mechanism, failure handling |
| 5. Implementation tools | Fetch client, environment variables, Next.js redirects |
Quick index
1. Install & project structure
npm install next-auth@5 jwt-decode
npx auth secretEnvironment variables
# .env.local
AUTH_SECRET=your_generated_secret_here
API_URL=http://localhost:4000/api # Your custom backend base URL| Variable | Purpose |
|---|---|
AUTH_SECRET | Encrypts the Next-Auth session JWT cookie |
API_URL | Base URL for /login, /refresh, /users |
Folder structure
├── app/
│ ├── api/auth/[...nextauth]/route.ts
│ ├── login/page.tsx
│ └── dashboard/page.tsx
├── components/
│ ├── CredentialLoginForm.tsx
│ └── SessionErrorHandler.tsx # Redirect on refresh failure
├── lib/
│ ├── api-client.ts # Fetch with Bearer token
│ └── refresh-access-token.ts # Calls backend /refresh
├── types/
│ └── next-auth.d.ts # Extend Session & JWT types
└── auth.ts # Credentials + rotation callbacks2. Token fundamentals
Your custom backend issues two tokens after login. Next-Auth stores both inside its encrypted session JWT and rotates the access token automatically.
Access token
| Property | Typical value | Purpose |
|---|---|---|
| Lifetime | Short — e.g. 1–15 minutes | Sent as Authorization: Bearer on API calls |
| Format | JWT | Self-contained; backend verifies signature |
| Storage | Next-Auth JWT callback (server-side cookie) | Never expose in localStorage |
{
"sub": "user-123",
"email": "jane@example.com",
"iat": 1710000000,
"exp": 1710000060
}Refresh token
| Property | Typical value | Purpose |
|---|---|---|
| Lifetime | Long — e.g. 24 hours to 30 days | Used only to get a new access token |
| Format | Opaque string or JWT | Sent to /refresh — never on regular API calls |
| Rotation | New refresh token issued on each refresh | Old refresh token invalidated (rotation) |
JWT (JSON Web Token)
Both tokens are often JWTs — three Base64URL segments separated by dots.
┌─────────────────────┬─────────────────────┬─────────────────────┐
│ Base64URL(header) │ Base64URL(payload) │ signature │
│ alg, typ claims │ sub, email, exp │ HMAC or RSA sign │
└─────────────────────┴─────────────────────┴─────────────────────┘
Format: <header-part>.<payload-part>.<signature-part>Decoded header example: { "alg": "HS256", "typ": "JWT" }
Decoded payload example: { "sub": "user-123", "email": "jane@example.com", "exp": 1710000060 }
| Part | Contains |
|---|---|
| Header | Algorithm (alg), token type (typ) |
| Payload | Claims — sub, email, exp (expiry unix timestamp) |
| Signature | Proves token was issued by your backend |
3. Backend API endpoints
Your custom backend must expose four endpoints. Next-Auth's authorize and rotation logic call /login and /refresh; your app calls /users with the access token.
Endpoint overview
| Endpoint | Method | Auth required | Returns |
|---|---|---|---|
/register | POST | No | { user } — 201 |
/login | POST | No | { user, accessToken, refreshToken } |
/users | GET | Bearer access token | { user } — protected route example |
/refresh | POST | Refresh token in body | { accessToken, refreshToken, expiresIn } |
/register — create user
// Backend (Express example) — POST /api/register
app.post('/api/register', async (req, res) => {
const { name, email, password } = req.body
const hashed = await bcrypt.hash(password, 10)
const user = await User.create({ name, email, password: hashed })
res.status(201).json({ user: { id: user.id, name, email } })
})/login — authenticate and return tokens
// Backend — POST /api/login
app.post('/api/login', async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const accessToken = jwt.sign({ sub: user.id, email }, SECRET, { expiresIn: '1m' })
const refreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, { expiresIn: '24h' })
// Store refresh token hash in DB for rotation validation
await RefreshToken.create({ userId: user.id, token: hash(refreshToken) })
res.json({
user: { id: user.id, name: user.name, email: user.email },
accessToken,
refreshToken,
expiresIn: 60,
})
})/users — protected route example
// Backend — GET /api/users (requires valid access token)
app.get('/api/users', authenticateAccessToken, (req, res) => {
res.json({ user: req.user })
})
function authenticateAccessToken(req, res, next) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' })
try {
req.user = jwt.verify(header.slice(7), SECRET)
next()
} catch {
res.status(401).json({ error: 'Token expired or invalid' })
}
}/refresh — renew access token (with rotation)
// Backend — POST /api/refresh
app.post('/api/refresh', async (req, res) => {
const { refreshToken } = req.body
if (!refreshToken) return res.status(400).json({ error: 'Refresh token required' })
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET)
// Validate token exists in DB and has not been rotated away
const stored = await RefreshToken.findOne({ userId: payload.sub })
if (!stored || !compareHash(refreshToken, stored.token)) {
return res.status(403).json({ error: 'Invalid refresh token' })
}
const user = await User.findById(payload.sub)
const newAccessToken = jwt.sign({ sub: user.id, email: user.email }, SECRET, { expiresIn: '1m' })
const newRefreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, { expiresIn: '24h' })
// Rotate — invalidate old, store new
await RefreshToken.updateOne({ userId: user.id }, { token: hash(newRefreshToken) })
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 60 })
} catch {
res.status(403).json({ error: 'Refresh token expired' })
}
})4. Next-Auth core concepts
Credentials provider — authorize function
The authorize function makes the initial API login call to your custom backend. It returns user data plus both tokens.
// auth.ts (partial)
import Credentials from 'next-auth/providers/credentials'
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const response = await fetch(`${process.env.API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: credentials?.email,
password: credentials?.password,
}),
})
const data = await response.json()
if (!response.ok) return null
return {
id: data.user.id,
name: data.user.name,
email: data.user.email,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresIn: data.expiresIn,
}
},
})Extend TypeScript types
// types/next-auth.d.ts
import 'next-auth'
import 'next-auth/jwt'
declare module 'next-auth' {
interface Session {
accessToken?: string
error?: string
}
}
declare module 'next-auth/jwt' {
interface JWT {
accessToken?: string
refreshToken?: string
accessTokenExpires?: number
error?: string
}
}JWT callback — store and update tokens
The JWT callback runs on every session access. It stores tokens on first login and triggers rotation when the access token expires.
callbacks: {
async jwt({ token, user }) {
// Initial sign-in — persist backend tokens
if (user) {
return {
...token,
accessToken: user.accessToken,
refreshToken: user.refreshToken,
accessTokenExpires: Date.now() + (user.expiresIn ?? 60) * 1000,
}
}
// Access token still valid — return as-is
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
return token
}
// Access token expired — rotate
return refreshAccessToken(token)
},
}Session callback — expose tokens to the app
The session callback maps JWT fields onto the session object available in Server Components and useSession().
async session({ session, token }) {
session.accessToken = token.accessToken as string
session.error = token.error as string | undefined
if (token.sub) session.user.id = token.sub
return session
},| Callback | Runs when | Responsibility |
|---|---|---|
| authorize | User submits login form | Call backend /login, return tokens |
| jwt | Every session read | Store tokens, check expiry, call /refresh |
| session | Every auth() / useSession() | Expose accessToken to app |
5. Token rotation logic
Expiry check — JWT decode library
Use jwt-decode to read the exp claim without verifying the signature (verification happens on your backend). Alternatively, track expiry with accessTokenExpires timestamp from your backend's expiresIn.
// lib/refresh-access-token.ts
import { jwtDecode } from 'jwt-decode'
type JwtPayload = { exp: number }
export function isTokenExpired(accessToken: string): boolean {
try {
const decoded = jwtDecode<JwtPayload>(accessToken)
// Compare Date.now() with exp (exp is in seconds, Date.now() in ms)
return Date.now() >= decoded.exp * 1000
} catch {
return true
}
}// In jwt callback — timestamp approach (preferred, no decode needed)
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
return token // still valid
}
// else → refresh| Approach | Pros |
|---|---|
accessTokenExpires timestamp | No decode overhead; backend controls lifetime via expiresIn |
jwt-decode + exp | Useful when backend does not return expiresIn |
Refresh mechanism
// lib/refresh-access-token.ts
import type { JWT } from 'next-auth/jwt'
export async function refreshAccessToken(token: JWT): Promise<JWT> {
try {
const response = await fetch(`${process.env.API_URL}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: token.refreshToken }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error ?? 'Refresh failed')
}
return {
...token,
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? token.refreshToken,
accessTokenExpires: Date.now() + (data.expiresIn ?? 60) * 1000,
error: undefined,
}
} catch {
return { ...token, error: 'RefreshAccessTokenError' }
}
}User active on dashboard
↓
auth() called → jwt callback runs
↓
Date.now() < accessTokenExpires? → return token (no API call)
↓ (expired)
POST /refresh { refreshToken }
↓
Backend rotates tokens → new access + refresh
↓
Update Next-Auth JWT cookie → seamless UXFailure handling — 403 and redirect to login
When /refresh returns 403 (expired or revoked refresh token), set an error on the token and redirect the user to login.
// lib/refresh-access-token.ts — on failure
return { ...token, error: 'RefreshAccessTokenError' }// components/SessionErrorHandler.tsx
'use client'
import { useSession, signOut } from 'next-auth/react'
import { useEffect } from 'react'
export function SessionErrorHandler() {
const { data: session } = useSession()
useEffect(() => {
if (session?.error === 'RefreshAccessTokenError') {
signOut({ callbackUrl: '/login?error=SessionExpired' })
}
}, [session?.error])
return null
}// app/layout.tsx
import { SessionProvider } from 'next-auth/react'
import { SessionErrorHandler } from '@/components/SessionErrorHandler'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SessionProvider>
<SessionErrorHandler />
{children}
</SessionProvider>
</body>
</html>
)
}| Failure | Backend status | Next-Auth action |
|---|---|---|
| Refresh token expired | 403 | Set RefreshAccessTokenError → sign out |
| Refresh token reused (rotation attack) | 403 | Revoke all sessions → sign out |
| Network error | — | Retry or sign out after N failures |
Full auth.ts
// auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { refreshAccessToken } from '@/lib/refresh-access-token'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const res = await fetch(`${process.env.API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: credentials?.email,
password: credentials?.password,
}),
})
const data = await res.json()
if (!res.ok) return null
return {
id: data.user.id,
name: data.user.name,
email: data.user.email,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresIn: data.expiresIn,
}
},
}),
],
session: { strategy: 'jwt' },
pages: { signIn: '/login' },
callbacks: {
async jwt({ token, user }) {
if (user) {
return {
...token,
accessToken: user.accessToken,
refreshToken: user.refreshToken,
accessTokenExpires: Date.now() + ((user as { expiresIn?: number }).expiresIn ?? 60) * 1000,
}
}
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
return token
}
return refreshAccessToken(token)
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
session.error = token.error as string | undefined
if (token.sub) session.user.id = token.sub
return session
},
},
})6. Implementation tools
Fetch client — custom header management
Attach the backend access token as a Bearer header on every protected API call.
// lib/api-client.ts
import { auth } from '@/auth'
type FetchOptions = RequestInit & {
auth?: boolean
}
export async function apiClient(endpoint: string, options: FetchOptions = {}) {
const { auth: requireAuth = true, headers, ...rest } = options
const requestHeaders = new Headers(headers)
requestHeaders.set('Content-Type', 'application/json')
if (requireAuth) {
const session = await auth()
if (!session?.accessToken) {
throw new Error('No access token — user not authenticated')
}
requestHeaders.set('Authorization', `Bearer ${session.accessToken}`)
}
const response = await fetch(`${process.env.API_URL}${endpoint}`, {
...rest,
headers: requestHeaders,
})
if (response.status === 401) {
throw new Error('Unauthorized — token may have expired')
}
if (response.status === 403) {
throw new Error('Forbidden — insufficient permissions')
}
return response
}// Usage in Server Component or Server Action
import { apiClient } from '@/lib/api-client'
export async function getUserProfile() {
const response = await apiClient('/users')
return response.json()
}Environment variables
# .env.local
AUTH_SECRET=...
API_URL=http://localhost:4000/api
# Production
API_URL=https://api.yourdomain.com/api| Variable | Used by |
|---|---|
API_URL | authorize, refreshAccessToken, apiClient |
AUTH_SECRET | Next-Auth session cookie encryption |
Next navigation — redirects on unauthorized
Server-side redirect when session is missing or refresh failed.
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect('/login')
if (session.error === 'RefreshAccessTokenError') redirect('/login?error=SessionExpired')
const profile = await getUserProfile()
// ...
}// app/login/page.tsx — show session expired message
import { Suspense } from 'react'
function ExpiredBanner({ searchParams }: { searchParams: { error?: string } }) {
if (searchParams.error !== 'SessionExpired') return null
return (
<p role="alert" className="mb-4 rounded-lg border border-amber-400 bg-amber-50 px-4 py-3 text-sm">
Your session expired. Please sign in again.
</p>
)
}7. Protected pages & API calls
Server Component with rotated token
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { apiClient } from '@/lib/api-client'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect('/login')
if (session.error) redirect('/login?error=SessionExpired')
// auth() triggers jwt callback → rotation if access token expired
const response = await apiClient('/users')
const { user } = await response.json()
return (
<main className="mx-auto max-w-2xl px-6 py-20">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-4">Hello, {user.name}</p>
<p className="text-sm text-neutral-600">{user.email}</p>
</main>
)
}End-to-end token lifecycle
1. Login → authorize() → POST /login → store access + refresh in JWT cookie
2. API call → auth() → jwt callback checks expiry
3. Valid → apiClient attaches Bearer accessToken → GET /users → 200
4. Expired → jwt callback → POST /refresh → new tokens → retry flow transparent
5. Refresh fail → error on session → SessionErrorHandler → signOut → /login8. Production notes & troubleshooting
Security checklist
| Practice | Why |
|---|---|
Rotate refresh tokens on every /refresh | Detect token theft via reuse |
| Store refresh token hashes in DB | Validate and revoke server-side |
| Short access token lifetime | Limits blast radius of leaked tokens |
| HTTPS only in production | Protect tokens in transit |
| Never log tokens | Access and refresh tokens are secrets |
Handle RefreshAccessTokenError | Force re-login when refresh fails |
Common errors
| Error | Cause | Fix |
|---|---|---|
| API returns 401 immediately | Access token expired, rotation not running | Verify jwt callback calls refreshAccessToken |
| Infinite refresh loop | /refresh always returns new token with past exp | Check backend expiresIn and clock sync |
RefreshAccessTokenError stuck | No SessionErrorHandler | Add client sign-out on session.error |
accessToken undefined in session | Missing session callback mapping | Map token.accessToken in session callback |
CORS errors on /login | Browser calling backend directly | Call backend from authorize (server-side only) |
Summary
| Mind map branch | Key pieces |
|---|---|
| Token fundamentals | Access token (short), refresh token (long), JWT structure |
| Backend API endpoints | /register, /login, /users, /refresh with rotation |
| Next-Auth core concepts | Credentials authorize, jwt + session callbacks |
| Token rotation logic | Expiry check, /refresh call, 403 failure → sign out |
| Implementation tools | apiClient with Bearer header, API_URL env, redirect() |
Related reads: Credential Provider, Register User To MongoDB, Next.js Middleware.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime