Implementing Secure JWT Authentication and Role-Based Access Control in Next.js
Design choices that matter here
Storing JWTs in localStorage is a common but avoidable XSS exposure. This implementation issues a short-lived access token and a longer-lived refresh token, both delivered as httpOnly, Secure, SameSite=Strict cookies — never exposed to client-side JavaScript. Route protection happens in Next.js Middleware (runs on every matching request, before the page renders), and role-based authorization is enforced both in Middleware (coarse-grained: can this role reach this route at all) and in each Route Handler (fine-grained: can this specific user perform this specific action).
1. Technical architecture
┌────────────┐ POST /api/auth/login ┌───────────────────────┐
│ Browser │ ───────────────────────▶│ Route Handler: verify │
│ │ │ credentials, issue JWTs │
└──────┬─────┘ └───────────┬───────────┘
│ Set-Cookie: access_token (15m), │
│ refresh_token (7d), both httpOnly │
▼ │
┌────────────────────────────────────────┐ │
│ Next.js Middleware (every request) │◀───────────────┘
│ 1. Read access_token cookie │
│ 2. Verify signature + expiry │
│ 3. Decode role claim │
│ 4. Check role against route's required role │
└──────┬───────────────────────┬────────────┘
│ valid │ expired/invalid
▼ ▼
┌────────────┐ ┌───────────────────────────┐
│ Allow request │ │ Redirect to /api/auth/refresh │
│ │ │ (rotates tokens) or /login │
└────────────┘ └───────────────────────────┘
2. Complete code implementation
2.1 Token utilities
// lib/auth/tokens.ts
import { SignJWT, jwtVerify, JWTPayload } 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 type Role = "admin" | "editor" | "viewer";
export interface AppJwtPayload extends JWTPayload {
sub: string; // user id
role: Role;
email: string;
}
export async function signAccessToken(payload: Omit<AppJwtPayload, "iat" | "exp">): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(ACCESS_SECRET);
}
export async function signRefreshToken(payload: Omit<AppJwtPayload, "iat" | "exp">): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(REFRESH_SECRET);
}
export async function verifyAccessToken(token: string): Promise<AppJwtPayload | null> {
try {
const { payload } = await jwtVerify(token, ACCESS_SECRET);
return payload as AppJwtPayload;
} catch {
return null; // covers expired, malformed, and bad-signature tokens uniformly
}
}
export async function verifyRefreshToken(token: string): Promise<AppJwtPayload | null> {
try {
const { payload } = await jwtVerify(token, REFRESH_SECRET);
return payload as AppJwtPayload;
} catch {
return null;
}
}
2.2 Login route handler
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { signAccessToken, signRefreshToken } from "@/lib/auth/tokens";
import { findUserByEmail } from "@/lib/db/users"; // your data-access layer
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const RATE_LIMIT_WINDOW_MS = 60_000;
const MAX_ATTEMPTS = 5;
const attempts = new Map<string, { count: number; windowStart: number }>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
attempts.set(ip, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(req: NextRequest) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(ip)) {
return NextResponse.json({ error: "Too many login attempts, try again shortly" }, { status: 429 });
}
const body = await req.json().catch(() => null);
const parsed = LoginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid request body", details: parsed.error.flatten() }, { status: 400 });
}
const user = await findUserByEmail(parsed.data.email);
// Deliberately generic error for both "no such user" and "wrong password" —
// distinguishing them lets an attacker enumerate valid emails.
const invalidCredentials = () =>
NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
if (!user) return invalidCredentials();
const passwordMatches = await bcrypt.compare(parsed.data.password, user.passwordHash);
if (!passwordMatches) return invalidCredentials();
const claims = { sub: user.id, role: user.role, email: user.email };
const accessToken = await signAccessToken(claims);
const refreshToken = await signRefreshToken(claims);
const response = NextResponse.json({ user: { id: user.id, email: user.email, role: user.role } });
response.cookies.set("access_token", accessToken, {
httpOnly: true, secure: true, sameSite: "strict", path: "/", maxAge: 60 * 15,
});
response.cookies.set("refresh_token", refreshToken, {
httpOnly: true, secure: true, sameSite: "strict", path: "/api/auth/refresh", maxAge: 60 * 60 * 24 * 7,
});
return response;
}
2.3 Refresh route handler (with rotation)
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyRefreshToken, signAccessToken, signRefreshToken } from "@/lib/auth/tokens";
import { isRefreshTokenRevoked, revokeRefreshToken } from "@/lib/auth/token-store";
export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get("refresh_token")?.value;
if (!refreshToken) {
return NextResponse.json({ error: "No refresh token present" }, { status: 401 });
}
const payload = await verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json({ error: "Refresh token invalid or expired" }, { status: 401 });
}
// Revocation check protects against a stolen-but-not-yet-expired refresh
// token being reused after the legitimate user (or an admin) has logged it out.
if (await isRefreshTokenRevoked(refreshToken)) {
return NextResponse.json({ error: "Refresh token has been revoked" }, { status: 401 });
}
const claims = { sub: payload.sub, role: payload.role, email: payload.email };
const newAccessToken = await signAccessToken(claims);
const newRefreshToken = await signRefreshToken(claims);
// Rotate: invalidate the old refresh token so it cannot be replayed.
await revokeRefreshToken(refreshToken);
const response = NextResponse.json({ ok: true });
response.cookies.set("access_token", newAccessToken, {
httpOnly: true, secure: true, sameSite: "strict", path: "/", maxAge: 60 * 15,
});
response.cookies.set("refresh_token", newRefreshToken, {
httpOnly: true, secure: true, sameSite: "strict", path: "/api/auth/refresh", maxAge: 60 * 60 * 24 * 7,
});
return response;
}
2.4 Middleware for route protection and RBAC
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyAccessToken, Role } from "@/lib/auth/tokens";
const ROUTE_ROLES: { prefix: string; roles: Role[] }[] = [
{ prefix: "/admin", roles: ["admin"] },
{ prefix: "/dashboard", roles: ["admin", "editor", "viewer"] },
{ prefix: "/api/content", roles: ["admin", "editor"] },
];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const matchedRule = ROUTE_ROLES.find((r) => pathname.startsWith(r.prefix));
if (!matchedRule) return NextResponse.next(); // not a protected route
const accessToken = req.cookies.get("access_token")?.value;
if (!accessToken) {
return redirectToLogin(req);
}
const payload = await verifyAccessToken(accessToken);
if (!payload) {
// Access token missing/expired — let the client-side auth wrapper call
// /api/auth/refresh and retry, rather than bouncing straight to login.
return NextResponse.json({ error: "token_expired" }, { status: 401 });
}
if (!matchedRule.roles.includes(payload.role)) {
return NextResponse.json({ error: "Forbidden: insufficient role" }, { status: 403 });
}
const response = NextResponse.next();
response.headers.set("x-user-id", payload.sub);
response.headers.set("x-user-role", payload.role);
return response;
}
function redirectToLogin(req: NextRequest) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("redirect", req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ["/admin/:path*", "/dashboard/:path*", "/api/content/:path*"],
};
3. Step-by-step configuration guide
- Install dependencies:
npm install jose bcryptjs zod - Generate strong, distinct secrets for access and refresh signing and store them in your environment — never reuse the same secret for both:
openssl rand -base64 48 # run twice, once per secret - Set environment variables in
.env.local(and your hosting provider's secret manager for production):JWT_ACCESS_SECRET=... JWT_REFRESH_SECRET=... - Implement
lib/db/users.ts(findUserByEmail) andlib/auth/token-store.ts(isRefreshTokenRevoked,revokeRefreshToken) against your actual database — a Redis set with TTL matching the refresh token lifetime is a good fit for the revocation store. - Add
middleware.tsat the project root (not insideapp/) exactly as shown — Next.js only picks up middleware from that specific location. - Test the flow: log in via
POST /api/auth/login, confirmSet-Cookieheaders for both tokens, then hit a protected route with and without valid cookies to confirm 200/401/403 behavior. - Add a client-side refresh interceptor (e.g., in your fetch wrapper) that catches a
401 {"error": "token_expired"}response, calls/api/auth/refresh, and retries the original request once.
4. Error handling and edge cases
- Expired or malformed access tokens are handled uniformly in
verifyAccessToken— both cases returnnullrather than throwing, so Middleware has one clean branch instead of needing to distinguish "expired" from "tampered" (which it shouldn't reveal to the client anyway, to avoid leaking information useful for forging tokens). - User enumeration via login errors is explicitly prevented —
login/route.tsreturns the identical "Invalid email or password" message and status code whether the email doesn't exist or the password is wrong. - Refresh token replay after logout/compromise is mitigated with rotation: every successful refresh issues a brand-new refresh token and immediately revokes the old one via
revokeRefreshToken, so a leaked-but-already-used refresh token cannot be replayed even before its natural expiry. - Brute-force login attempts are throttled with a simple in-memory rate limiter keyed by IP (§2.2); swap this for a shared store (Redis) in any multi-instance deployment, since the in-memory
Mapshown here only rate-limits per server process. - Cookie scoping — the refresh token cookie's
pathis scoped to/api/auth/refreshonly, so it is never sent on ordinary page/API requests, shrinking its exposure window even though it's alreadyhttpOnly. - Coarse vs. fine-grained authorization — Middleware only checks "does this role belong on this route at all" (
ROUTE_ROLES); it deliberately does not check per-resource ownership (e.g., "can this editor edit this specific document"), which belongs in the Route Handler itself, where the full resource context is available. - Clock skew between servers —
jose'sjwtVerifyallows a small default clock tolerance; if you run multiple servers with drifting clocks, set an explicitclockToleranceoption rather than relying on the default, and keep NTP sync in place across your fleet regardless. - Missing/misconfigured secrets in production — both
ACCESS_SECRETandREFRESH_SECRETare read with a non-null assertion (!) fromprocess.env; add a startup check that fails fast (rather than throwing on the first request) if either is unset, so a misconfigured deployment is caught at boot, not by your first user's failed login.