Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
Next.jsNext-AuthJWTRefresh TokenCredentialsApp Router

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.

Jul 6, 202618 min read

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.

plaintext
    Refresh Token Rotation with Next-Auth V5
                    │
    ┌───────┬───────┼───────┬──────────┬──────────┐
    │       │       │       │          │          │
  Token  Backend  Next-Auth  Rotation   Implementation
Fundamentals Endpoints  Core      Logic        Tools

What you will build:

BranchWhat it covers
1. Token fundamentalsAccess token, refresh token, JWT structure
2. Backend API endpoints/register, /login, /users, /refresh
3. Next-Auth core conceptsCredentials provider, authorize, JWT & session callbacks
4. Token rotation logicExpiry check, refresh mechanism, failure handling
5. Implementation toolsFetch client, environment variables, Next.js redirects

Note

This pattern is for apps where a separate API (Express, FastAPI, NestJS, etc.) owns user records and token issuance. Next-Auth does not replace your backend — it bridges the browser session to your API tokens.

Quick index

#Section
1Install & project structure
2Token fundamentals
3Backend API endpoints
4Next-Auth core concepts
5Token rotation logic
6Implementation tools
7Protected pages & API calls
8Production notes & troubleshooting

1. Install & project structure

bash
npm install next-auth@5 jwt-decode
npx auth secret

Environment variables

bash
# .env.local
AUTH_SECRET=your_generated_secret_here
API_URL=http://localhost:4000/api    # Your custom backend base URL
VariablePurpose
AUTH_SECRETEncrypts the Next-Auth session JWT cookie
API_URLBase URL for /login, /refresh, /users

Folder structure

plaintext
├── 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 callbacks

Back to index


2. 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

PropertyTypical valuePurpose
LifetimeShort — e.g. 1–15 minutesSent as Authorization: Bearer on API calls
FormatJWTSelf-contained; backend verifies signature
StorageNext-Auth JWT callback (server-side cookie)Never expose in localStorage
json
{
  "sub": "user-123",
  "email": "jane@example.com",
  "iat": 1710000000,
  "exp": 1710000060
}

Refresh token

PropertyTypical valuePurpose
LifetimeLong — e.g. 24 hours to 30 daysUsed only to get a new access token
FormatOpaque string or JWTSent to /refresh — never on regular API calls
RotationNew refresh token issued on each refreshOld refresh token invalidated (rotation)

Warning

With refresh token rotation, each /refresh call invalidates the previous refresh token and issues a new pair. If a stolen refresh token is reused, the backend can detect reuse and revoke all sessions for that user.

JWT (JSON Web Token)

Both tokens are often JWTs — three Base64URL segments separated by dots.

plaintext
┌─────────────────────┬─────────────────────┬─────────────────────┐
│  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 }

PartContains
HeaderAlgorithm (alg), token type (typ)
PayloadClaims — sub, email, exp (expiry unix timestamp)
SignatureProves token was issued by your backend

Note

Next-Auth's session cookie is also a JWT — but it wraps your backend tokens. Do not confuse the Next-Auth session JWT (encrypted cookie) with your backend access JWT (sent to your API).

Tip

Use short access token lifetimes (1–15 min) and longer refresh tokens (days). Short access tokens limit damage if one is leaked; refresh tokens handle seamless renewal.

Back to index


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

EndpointMethodAuth requiredReturns
/registerPOSTNo{ user } — 201
/loginPOSTNo{ user, accessToken, refreshToken }
/usersGETBearer access token{ user } — protected route example
/refreshPOSTRefresh token in body{ accessToken, refreshToken, expiresIn }

/register — create user

ts
// 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

ts
// 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

ts
// 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)

ts
// 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' })
  }
})

Performance

Return expiresIn (seconds) from /login and /refresh so Next-Auth can set accessTokenExpires without decoding the JWT on every request.

Back to index


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.

ts
// 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

ts
// 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.

ts
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().

ts
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
},
CallbackRuns whenResponsibility
authorizeUser submits login formCall backend /login, return tokens
jwtEvery session readStore tokens, check expiry, call /refresh
sessionEvery auth() / useSession()Expose accessToken to app

Note

Keep accessToken and refreshToken in the JWT callback (server-side encrypted cookie). Only expose accessToken to the session if client components need it. Never send refreshToken to the browser if avoidable.

Back to index


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.

ts
// 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
  }
}
ts
// In jwt callback — timestamp approach (preferred, no decode needed)
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
  return token // still valid
}
// else → refresh
ApproachPros
accessTokenExpires timestampNo decode overhead; backend controls lifetime via expiresIn
jwt-decode + expUseful when backend does not return expiresIn

Refresh mechanism

ts
// 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' }
  }
}
plaintext
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 UX

Tip

Rotation runs inside the jwt callback — triggered whenever auth() or useSession() reads the session. Active users stay logged in without noticing; idle users past refresh token expiry get signed out.

Failure 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.

ts
// lib/refresh-access-token.ts — on failure
return { ...token, error: 'RefreshAccessTokenError' }
tsx
// 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
}
tsx
// 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>
  )
}
FailureBackend statusNext-Auth action
Refresh token expired403Set RefreshAccessTokenError → sign out
Refresh token reused (rotation attack)403Revoke all sessions → sign out
Network error—Retry or sign out after N failures

Warning

Always handle RefreshAccessTokenError on the client. Without it, the app keeps a broken session — API calls fail with 401 but the user sees no logout prompt.

Full auth.ts

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
    },
  },
})

Back to index


6. Implementation tools

Fetch client — custom header management

Attach the backend access token as a Bearer header on every protected API call.

ts
// 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
}
ts
// 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

bash
# .env.local
AUTH_SECRET=...
API_URL=http://localhost:4000/api
 
# Production
API_URL=https://api.yourdomain.com/api
VariableUsed by
API_URLauthorize, refreshAccessToken, apiClient
AUTH_SECRETNext-Auth session cookie encryption

Warning

API_URL is server-only — do not prefix with NEXT_PUBLIC_. The fetch client runs in Server Components and Server Actions where env vars are not exposed to the browser.

Next navigation — redirects on unauthorized

Server-side redirect when session is missing or refresh failed.

tsx
// 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()
  // ...
}
tsx
// 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>
  )
}

Tip

Use redirect() from next/navigation in Server Components for auth gates. Use signOut({ callbackUrl: '/login' }) on the client when SessionErrorHandler detects a refresh failure mid-session.

Back to index


7. Protected pages & API calls

Server Component with rotated token

tsx
// 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

plaintext
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 → /login

Note

Call auth() once per request in Server Components — it triggers rotation if needed before apiClient reads session.accessToken. Avoid caching session across requests without re-validating.

Back to index


8. Production notes & troubleshooting

Security checklist

PracticeWhy
Rotate refresh tokens on every /refreshDetect token theft via reuse
Store refresh token hashes in DBValidate and revoke server-side
Short access token lifetimeLimits blast radius of leaked tokens
HTTPS only in productionProtect tokens in transit
Never log tokensAccess and refresh tokens are secrets
Handle RefreshAccessTokenErrorForce re-login when refresh fails

Common errors

ErrorCauseFix
API returns 401 immediatelyAccess token expired, rotation not runningVerify jwt callback calls refreshAccessToken
Infinite refresh loop/refresh always returns new token with past expCheck backend expiresIn and clock sync
RefreshAccessTokenError stuckNo SessionErrorHandlerAdd client sign-out on session.error
accessToken undefined in sessionMissing session callback mappingMap token.accessToken in session callback
CORS errors on /loginBrowser calling backend directlyCall backend from authorize (server-side only)

Performance

The jwt callback runs on every auth() call. Use accessTokenExpires timestamp check before calling /refresh — avoid hitting your backend on every Server Component render when the token is still valid.

Interview Answer

"How do you implement refresh token rotation with Next-Auth v5 and a custom backend?"

Use the Credentials provider — authorize calls your backend /login and returns accessToken, refreshToken, and expiresIn. In the jwt callback, store tokens on first login. On subsequent calls, compare Date.now() with accessTokenExpires; if expired, POST to /refresh, update both tokens (rotation), and return the new JWT. In the session callback, expose accessToken for API calls. On 403 from /refresh, set RefreshAccessTokenError and sign the user out. Use a server-side apiClient with Authorization: Bearer headers.

Back to index


Summary

Mind map branchKey pieces
Token fundamentalsAccess token (short), refresh token (long), JWT structure
Backend API endpoints/register, /login, /users, /refresh with rotation
Next-Auth core conceptsCredentials authorize, jwt + session callbacks
Token rotation logicExpiry check, /refresh call, 403 failure → sign out
Implementation toolsapiClient with Bearer header, API_URL env, redirect()

Related reads: Credential Provider, Register User To MongoDB, Next.js Middleware.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime