← Back to list

JWT Auth in Next.js 16 App Router — Updated for 2026

The patterns for JWT auth in Next.js changed significantly. Here’s the current best practice — no outdated Pages Router patterns.

Chirag Mehta · 2026-06-19 04:31 · 0 claps · 4.2 min read
#nextjs #authentication #javascript #web-development #app-router
Open on Medium ↗
Wiki topics: 🌐 · Web Development

JWT Auth in Next.js 16 App Router — Updated for 2026

The patterns for JWT auth in Next.js changed significantly. Here’s the current best practice — no outdated Pages Router patterns.

PUBLISH METADATA

  • Tags: Next.js, Authentication, JavaScript, Web Development, Security
  • Description: A complete 2026 guide to implementing JWT authentication in Next.js 16 App Router — covering token storage, middleware-based route protection, refresh token rotation, and server component auth patterns.
  • Suggested publish date: Week 3 — Thursday
  • Suggested cover image: Next.js logo with a lock/JWT token icon. Dark background, blue accent.

Introduction

My old article on JWT auth in Next.js (from 2024) still gets reads. But it used the Pages Router pattern with getServerSideProps. In the App Router, auth works completely differently.

This is the updated, App Router-native approach for 2026. We’ll implement:

  • JWT access + refresh token pair
  • HTTP-only cookie storage (no localStorage)
  • Middleware-based route protection
  • Server Component auth (no prop drilling)
  • Automatic token refresh

Why HTTP-Only Cookies, Not localStorage

This question still comes up. Short answer:

StorageXSS VulnerableCSRF VulnerablelocalStorage✅ YES — JS can steal tokensNoHTTP-only CookieNo — JS can’t access✅ YES (mitigated with SameSite)HTTP-only + SameSite=StrictNoNo

Use HTTP-only cookies with SameSite=Strict. Done.

Step 1: Token Generation Utilities

// lib/auth/tokens.ts
import { SignJWT, jwtVerify } from 'jose'
const ACCESS_SECRET = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET!)
const REFRESH_SECRET = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET!)
export async function signAccessToken(userId: string) {
  return new SignJWT({ userId, type: 'access' })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('15m')
    .setIssuedAt()
    .sign(ACCESS_SECRET)
}
export async function signRefreshToken(userId: string) {
  return new SignJWT({ userId, type: 'refresh' })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('7d')
    .setIssuedAt()
    .sign(REFRESH_SECRET)
}
export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, ACCESS_SECRET)
  return payload as { userId: string; type: string }
}
export async function verifyRefreshToken(token: string) {
  const { payload } = await jwtVerify(token, REFRESH_SECRET)
  return payload as { userId: string; type: string }
}

Step 2: Login Server Action

// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { signAccessToken, signRefreshToken } from '@/lib/auth/tokens'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'
export async function login(formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string
  const user = await db.user.findUnique({ where: { email } })
  if (!user) return { error: 'Invalid credentials' }
  const valid = await bcrypt.compare(password, user.passwordHash)
  if (!valid) return { error: 'Invalid credentials' }
  const accessToken = await signAccessToken(user.id)
  const refreshToken = await signRefreshToken(user.id)
  // Store refresh token in DB for rotation tracking
  await db.refreshToken.create({
    data: { token: refreshToken, userId: user.id, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
  })
  const cookieStore = cookies()
  cookieStore.set('access_token', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 15 * 60, // 15 minutes
    path: '/',
  })
  cookieStore.set('refresh_token', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60, // 7 days
    path: '/api/auth/refresh', // Only sent to refresh endpoint
  })
  redirect('/dashboard')
}
export async function logout() {
  const cookieStore = cookies()
  cookieStore.delete('access_token')
  cookieStore.delete('refresh_token')
  redirect('/login')
}

Step 3: Middleware Route Protection

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyAccessToken } from '@/lib/auth/tokens'
const PROTECTED_ROUTES = ['/dashboard', '/settings', '/api/v1']
const AUTH_ROUTES = ['/login', '/signup']
export async function middleware(request: NextRequest) {
  const path = request.nextUrl.pathname
  const isProtected = PROTECTED_ROUTES.some(route => path.startsWith(route))
  const isAuthRoute = AUTH_ROUTES.some(route => path.startsWith(route))
  const accessToken = request.cookies.get('access_token')?.value
  if (isProtected) {
    if (!accessToken) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
    try {
      await verifyAccessToken(accessToken)
      return NextResponse.next()
    } catch {
      // Token expired or invalid — redirect to refresh or login
      const response = NextResponse.redirect(new URL('/api/auth/refresh', request.url))
      response.headers.set('x-redirect-to', path) // Remember where they were going
      return response
    }
  }
  if (isAuthRoute && accessToken) {
    try {
      await verifyAccessToken(accessToken)
      return NextResponse.redirect(new URL('/dashboard', request.url))
    } catch {
      // Expired token on auth page — let them log in again
    }
  }
  return NextResponse.next()
}
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Step 4: Token Refresh Route

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyRefreshToken, signAccessToken } from '@/lib/auth/tokens'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
  const refreshToken = request.cookies.get('refresh_token')?.value
  const redirectTo = request.headers.get('x-redirect-to') || '/dashboard'
  if (!refreshToken) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  try {
    const { userId } = await verifyRefreshToken(refreshToken)
    // Check token is in DB (not revoked)
    const storedToken = await db.refreshToken.findUnique({
      where: { token: refreshToken }
    })
    if (!storedToken || storedToken.expiresAt < new Date()) {
      throw new Error('Token revoked or expired')
    }
    // Issue new access token
    const newAccessToken = await signAccessToken(userId)
    const cookieStore = cookies()
    cookieStore.set('access_token', newAccessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60,
      path: '/',
    })
    return NextResponse.redirect(new URL(redirectTo, request.url))
  } catch {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

Step 5: Auth Helper for Server Components

// lib/auth/session.ts
import { cookies } from 'next/headers'
import { verifyAccessToken } from './tokens'
import { db } from '@/lib/db'
import { cache } from 'react'
// `cache` deduplicates calls within one request
export const getCurrentUser = cache(async () => {
  const token = cookies().get('access_token')?.value
  if (!token) return null
  try {
    const { userId } = await verifyAccessToken(token)
    return db.user.findUnique({
      where: { id: userId },
      select: { id: true, email: true, name: true, role: true }
    })
  } catch {
    return null
  }
})
// app/dashboard/page.tsx
import { getCurrentUser } from '@/lib/auth/session'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
  const user = await getCurrentUser()
  if (!user) redirect('/login')
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
    </div>
  )
}

No prop drilling. No context provider for auth. Just getCurrentUser() anywhere in your Server Component tree.

Conclusion

JWT auth in Next.js 16 App Router is cleaner than the old Pages Router approach once you understand the primitives: Server Actions for login/logout, middleware for route protection, and cache() for session sharing across server components.

The key differences from 2024 patterns:

  • No getServerSideProps — use cache() + cookies()
  • No /pages/api/auth — use /app/api/auth route handlers
  • Forms use Server Actions directly, not fetch calls

#Nextjs #Authentication #JavaScript #WebDevelopment #Security #JWT #AppRouter #React #FullStack

I’m a Full-Stack Developer specializing in React, Next.js, and Node.js. 👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/

[embed]chiragmehta900 — Overview Hey, I am Chirag Mehta, a professional MERN — Stack developer with +5 years of experience. — chiragmehta900github.com


메타데이터
post_id
2c92cf17de9f
slug
jwt-auth-in-next-js-16-app-router-updated-for-2026-2c92cf17de9f
url
https://medium.com/@chiragmehta900/jwt-auth-in-next-js-16-app-router-updated-for-2026-2c92cf17de9f
canonical_url
https://medium.com/@chiragmehta900/jwt-auth-in-next-js-16-app-router-updated-for-2026-2c92cf17de9f
author_url
https://medium.com/@chiragmehta900
status
ok
fetched_at
2026-06-20 20:29:01