CalcuOnline
HomeTech Tutorials / ReviewsDeveloper ToolsImplementing Secure JWT Authentication and Role-Based Access Control in Next.js
Implementing Secure JWT Authentication and Role-Based Access Control in Next.js

Implementing Secure JWT Authentication and Role-Based Access Control in Next.js

Developer Tools 5.0 Updated 24 August 2026

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

  1. Install dependencies:
    npm install jose bcryptjs zod
    
  2. 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
    
  3. Set environment variables in .env.local (and your hosting provider's secret manager for production):
    JWT_ACCESS_SECRET=...
    JWT_REFRESH_SECRET=...
    
  4. Implement lib/db/users.ts (findUserByEmail) and lib/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.
  5. Add middleware.ts at the project root (not inside app/) exactly as shown — Next.js only picks up middleware from that specific location.
  6. Test the flow: log in via POST /api/auth/login, confirm Set-Cookie headers for both tokens, then hit a protected route with and without valid cookies to confirm 200/401/403 behavior.
  7. 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

Related Reviews

Building a Production-Ready RAG Pipeline with LangChain, Pinecone, and OpenAI
Building a Production-Ready RAG Pipeline with LangChain, Pinecone, and OpenAI
5.0
A complete retrieval-augmented generation pipeline covering chunking strategy, Pinecone indexing with metadata filters, hybrid retrieval, and a FastAPI se…
Read Review → Developer Tools
Optimizing AI Model Inference Latency in Production Using NVIDIA Triton Inference Server
Optimizing AI Model Inference Latency in Production Using NVIDIA Triton Inference Server
5.0
Deploy a model behind NVIDIA Triton with dynamic batching, concurrent model instances, and ONNX Runtime acceleration, then measure and tune p99 latency un…
Read Review → Developer Tools
Setting Up Automated LLM Evaluation Pipelines for Hallucination Detection Using Ragas
Setting Up Automated LLM Evaluation Pipelines for Hallucination Detection Using Ragas
5.0
Wire Ragas metrics (faithfulness, answer relevancy, context precision/recall) into a CI-runnable evaluation pipeline that scores a RAG system against a go…
Read Review → Developer Tools