Posted on: 30/05/2026(updated)
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact, self-contained method for securely transmitting information between parties as a JSON object.
While the core cryptographic principles of JWTs remain stable, the strategy for implementing them has radically shifted. With modern full-stack frameworks like Next.js shifting processing to the server, traditional client-side patterns are outdated. Storing tokens where client-side scripts can access them opens the door to devastating security vulnerabilities.
This guide breaks down exactly what JWTs are, when you should use them, how the underlying process works, and how to implement them securely using modern framework architecture.
A JWT is a tool for verifying the ownership and integrity of data. When a server receives a valid token, it is cryptographically guaranteed that the data inside can be trusted because it was signed by a trusted source and has not been altered in transit.
A standard JWT guarantees integrity, not confidentiality. The data inside a JWT is merely serialized and Base64Url encoded—it is not encrypted. Anyone who intercepts the token can paste it into a decoder and read your entire payload in clear text.
Because of this transparency:
JWTs are not a magic bullet for every scenario, but they excel in specific architectural designs:
This is the most common use case. In a traditional session model, the server must look up a session ID in a database or cache on every single request. With JWTs, the authentication state is stored directly inside the token payload. The microservice or API endpoint can verify the token independently using a shared secret or public key without querying a central database, making it highly scalable.
JWTs are an excellent way to securely transmit data between independent microservices or third parties. Because tokens can be signed using public/private key pairs, the receiver can verify that the sender is exactly who they claim to be and ensure the content hasn’t been tampered with.
Because JWTs are self-contained and small enough to be passed via HTTP headers or cookies, they are widely used in SSO architectures. Once a user logs into a central Identity Provider (like Auth0, Clerk, Okta, or Cognito), a token is generated that allows the user to navigate across different web domains without logging in again.
To understand how JWT operates in production, it is important to trace the lifecycle of a request:
HTTP
Authorization: Bearer <your_jwt_token>
A JWT visually appears as a single string broken into three distinct parts separated by dots (.): header.payload.signature
JSON
{
"alg": "HS256",
"typ": "JWT"
}
This JSON object is Base64Url encoded to form the first part of the token.
JSON
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"exp": 1779870000
}
This object is Base64Url encoded to form the second part of the token.
If a malicious actor alters the payload, the recalculation of the signature on the server will fail to match, and the request will be rejected.
Where you store the token on the web client is the single most critical security decision you will make.
Many older tutorials suggest saving JWTs inside localStorage or sessionStorage because it is highly convenient for frontend JavaScript to access.
Do not do this. If an attacker manages to execute any script on your page via a Cross-Site Scripting (XSS) vulnerability—whether through a compromised third-party script, an analytics package, or un-sanitized user input—they can run localStorage.getItem() and steal your user's identity instantly.
To completely neutralize XSS-based token theft, you must store JWTs inside an HttpOnly cookie. When a cookie is marked as HttpOnly, browser-side JavaScript is completely blocked from reading or manipulating it.
The browser will automatically attach the cookie to outgoing HTTP requests to your domain, allowing your server to authenticate the request while keeping the token hidden from client-side code.
Modern Next.js applications run server-side code natively alongside UI code. This allows you to handle authentication entirely on the server side, eliminating token exposure to the browser altogether.
When a login form is submitted, we handle the authentication securely inside a Server Action. If valid, we sign the token and drop it directly into a secure cookie.
TypeScript
// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET || 'your-production-fallback-secret'
export async function handleLogin(formData: FormData) {
const email = formData.get('email')
const password = formData.get('password')
// 1. Perform database lookups and credential verification here
if (email === 'developer@example.com' && password === 'super-secure-password') {
// 2. Build the payload data
const payload = {
email,
role: 'admin',
}
// 3. Create the token with a defined expiration (e.g., 1 hour)
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' })
// 4. Safely inject into a secure, HttpOnly cookie
const cookieStore = await cookies()
cookieStore.set('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Enforce HTTPS in prod
sameSite: 'lax', // Basic CSRF defense
path: '/',
maxAge: 60 * 60, // 1 hour matching token lifetime
})
return { success: true }
}
return { success: false, error: 'Invalid email or password.' }
}
To block unauthorized users from viewing private routes, Next.js Middleware can intercept requests at the edge, handle cryptographic verification, and redirect users seamlessly before any page layout ever renders.
TypeScript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose' // Ideal for Next.js Edge runtime compatibility
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth_token')?.value
// Redirect to login if token is entirely missing
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
// Verifies integrity and checks if token is expired
await jwtVerify(token, JWT_SECRET)
return NextResponse.next()
} catch (error) {
// Invalid or expired token
return NextResponse.redirect(new URL('/login', request.url))
}
}
// Config rules to apply middleware only to protected directories
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
Because the cookie is handled on the server, you can cleanly parse token claims to serve customized views to your users directly inside React Server Components without any loading state flashes.
TypeScript
// app/dashboard/page.tsx
import { cookies } from 'next/headers'
import jwt from 'jsonwebtoken'
import { redirect } from 'next/navigation'
const JWT_SECRET = process.env.JWT_SECRET || 'your-production-fallback-secret'
interface UserTokenPayload {
email: string
role: string
}
export default async function Dashboard() {
const cookieStore = await cookies()
const token = cookieStore.get('auth_token')?.value
if (!token) {
redirect('/login')
}
let decodedUser: UserTokenPayload | null = null
try {
decodedUser = jwt.verify(token, JWT_SECRET) as UserTokenPayload
} catch (err) {
// If validation fails due to expiration or tampering
redirect('/login')
}
return (
<div className="max-w-4xl mx-auto p-12">
<h1 className="text-3xl font-extrabold tracking-tight">
System Dashboard
</h1>
<p className="mt-4 text-slate-600">
Authenticated Session Account:
<span className="underline font-semibold text-black">
{decodedUser.email}
</span>
</p>
<div className="mt-6 p-4 bg-slate-100 rounded-lg">
<p className="text-sm font-medium">
Clearance Level:
<span className="font-mono text-red-600 uppercase">
{decodedUser.role}
</span>
</p>
</div>
</div>
)
}