Auth
How to set up JWT authentication, middleware route protection, role-based access, and session management.
1. Architecture
Next.js acts as the frontend and BFF (Backend for Frontend). A separate backend (FeathersJS, FastAPI, etc.) owns user management and token issuance. Next.js only verifies tokens and protects routes. Two independent auth strategies are available: NextAuth (OAuth providers) and custom JWT (email/password via your backend).
Browser → Next.js (middleware) → Backend API (FeatherJS / FastAPI)
↑ verify JWT ↑ issue JWT, manage users
Flow:
1. User submits credentials → Next.js API route → Backend /auth/login
2. Backend returns JWT → Next.js sets httpOnly cookie
3. Subsequent requests: middleware reads cookie, verifies JWT
4. Protected API routes forward token to backend as Bearer headerTo wire it up
- Set
JWT_SECRET(32+ chars) andBACKEND_URLin.env.local— see §2. - Add routes to protect in the
protectedPathsarray inproxy.ts— see §8. - Define roles in the
ROLESconstant (§5); tune the cookiemaxAgein §4.
2. Environment Validation
The server validates all required environment variables at startup. If any are missing or invalid, the application crashes immediately with a clear error message. This is fail-fast — better to crash than to run with misconfiguration.
1 // server/env.ts 2 let validated = false; 3 const errors: string[] = []; 4 5 function validate(): void { 6 if (validated) return; 7 8 const jwtSecret = process.env.JWT_SECRET; 9 if (!jwtSecret || jwtSecret.length < 32) { 10 errors.push("JWT_SECRET must be at least 32 characters"); 11 } 12 13 const backendUrl = process.env.BACKEND_URL; 14 if (!backendUrl) { 15 errors.push("BACKEND_URL is required"); 16 } 17 18 if (errors.length > 0) { 19 throw new Error(`[Env Configuration Error]\n${errors.join("\n")}`); 20 } 21 22 validated = true; 23 } 24 25 export const env = { 26 get jwtSecret(): string { validate(); return process.env.JWT_SECRET!; }, 27 get backendUrl(): string { validate(); return process.env.BACKEND_URL!; }, 28 get isProduction(): boolean { validate(); return process.env.NODE_ENV === "production"; }, 29 } as const;
3. Token Verification
The verifyToken() function uses jose for edge-compatible JWT verification. It explicitly restricts the algorithm to HS256, adds clock tolerance for time drift, and validates the payload structure with Zod to ensure all expected claims are present and correctly typed.
1 // server/auth/auth.jwt.ts 2 import { jwtVerify } from "jose"; 3 import { JWTExpired } from "jose/errors"; 4 import { z } from "zod"; 5 import { env } from "@/server/env"; 6 import { ROLES } from "./types/auth.types"; 7 import type { AuthPayload } from "./types/auth.types"; 8 import { TokenExpiredError, InvalidTokenError } from "./types/auth.types"; 9 10 const authPayloadSchema = z.object({ 11 sub: z.string(), 12 email: z.string().email(), 13 name: z.string(), 14 role: z.enum(ROLES), 15 iat: z.number().optional(), 16 exp: z.number().optional(), 17 }); 18 19 export async function verifyToken(token: string): Promise<AuthPayload> { 20 try { 21 const { payload } = await jwtVerify(token, getSecret(), { 22 algorithms: ["HS256"], // Explicit algorithm restriction 23 clockTolerance: "5s", // Handle time drift 24 }); 25 26 const result = authPayloadSchema.safeParse(payload); 27 if (!result.success) { 28 throw new InvalidTokenError(); 29 } 30 31 return result.data; 32 } catch (error) { 33 if (error instanceof JWTExpired) throw new TokenExpiredError(); 34 if (error instanceof InvalidTokenError) throw error; 35 throw new InvalidTokenError(); 36 } 37 }
4. Cookie Security
A centralized cookie configuration ensures consistent security across all auth endpoints. In production, the __Host- prefix prevents subdomain cookie injection — the browser enforces that the cookie must be set with Secure, Path=/, and no Domainattribute. In development, a plain name is used since HTTP doesn't support the __Host- prefix.
1 // server/auth/auth.cookie.ts 2 import { env } from "@/server/env"; 3 4 export const AUTH_COOKIE_NAME = env.isProduction ? "__Host-token" : "token"; 5 6 export const AUTH_COOKIE_OPTIONS = { 7 httpOnly: true, 8 secure: env.isProduction, 9 sameSite: "lax" as const, 10 path: "/", 11 maxAge: 60 * 60 * 8, // 8 hours — align with backend token expiry 12 };
5. Role-Based Access Control
Use requireRole() in API routes that need authorization checks. It uses strict Role types to ensure only valid roles are accepted. Roles are never trusted from frontend input — only from verified JWT.
1 // server/auth/types/auth.types.ts 2 export const ROLES = ["admin", "user"] as const; 3 export type Role = (typeof ROLES)[number]; 4 5 // server/auth/auth.guard.ts 6 import { AUTH_COOKIE_NAME } from "./auth.cookie"; 7 import type { Role } from "./types/auth.types"; 8 9 export async function getAuthenticatedUser(request: NextRequest): Promise<AuthPayload> { 10 const token = request.cookies.get(AUTH_COOKIE_NAME)?.value; 11 if (!token) throw new InvalidTokenError(); 12 return verifyToken(token); 13 } 14 15 export async function requireRole( 16 request: NextRequest, 17 allowedRoles: Role[] 18 ): Promise<AuthPayload> { 19 const user = await getAuthenticatedUser(request); 20 if (!allowedRoles.includes(user.role)) { 21 throw new ForbiddenError(`Access denied. Required role: ${allowedRoles.join(" or ")}`); 22 } 23 return user; 24 }
6. Login API Route
The login route validates Content-Type, uses Zod for input validation with an 8-character minimum password, proxies credentials to your backend, and sets a secure httpOnly cookie on success using the centralized cookie config.
1 // app/api/auth/login/route.ts 2 import { z } from "zod"; 3 import { AUTH_COOKIE_NAME, AUTH_COOKIE_OPTIONS } from "@/server/auth/auth.cookie"; 4 import { authService } from "@/features/auth/services/auth.service"; 5 import { toApiError, AUTH_ERROR_CODES } from "@/server/auth/types/auth.types"; 6 7 // TODO: Add rate limiting (Redis or in-memory store) 8 9 const loginSchema = z.object({ 10 email: z.string().email("Invalid email format"), 11 password: z.string().min(8, "Password must be at least 8 characters"), 12 }); 13 14 export async function POST(request: NextRequest) { 15 try { 16 const contentType = request.headers.get("content-type"); 17 if (!contentType?.includes("application/json")) { 18 return NextResponse.json({ 19 success: false, 20 error: "Content-Type must be application/json", 21 code: AUTH_ERROR_CODES.INVALID_CREDENTIALS, 22 }, { status: 415 }); 23 } 24 25 const body = await request.json(); 26 const result = loginSchema.safeParse(body); 27 28 if (!result.success) { 29 return NextResponse.json({ 30 success: false, 31 error: result.error.errors[0].message, 32 code: AUTH_ERROR_CODES.INVALID_CREDENTIALS, 33 }, { status: 400 }); 34 } 35 36 const loginResult = await authService.login(result.data); 37 const response = NextResponse.json({ success: true, user: loginResult.user }); 38 response.cookies.set(AUTH_COOKIE_NAME, loginResult.token, AUTH_COOKIE_OPTIONS); 39 40 return response; 41 } catch (error) { 42 const apiError = toApiError(error as Error); 43 return NextResponse.json( 44 { success: false, error: apiError.error, code: apiError.code }, 45 { status: 401 } 46 ); 47 } 48 }
7. Service Layer
The auth service layer sits between API routes and the backend. It validates backend responses with Zod to catch contract violations early, and handles backend session invalidation on logout. If the backend is unreachable during logout, the cookie is still deleted — client-side logout always succeeds.
1 // features/auth/services/auth.service.ts 2 import { z } from "zod"; 3 import { ROLES } from "@/server/auth/types/auth.types"; 4 5 const loginResponseSchema = z.object({ 6 user: z.object({ 7 id: z.string(), 8 email: z.string().email(), 9 name: z.string(), 10 role: z.enum(ROLES), 11 }), 12 token: z.string(), 13 }); 14 15 async function login(credentials: LoginCredentials): Promise<LoginResponse> { 16 const res = await fetch(`${env.backendUrl}/auth/login`, { 17 method: "POST", 18 headers: { "Content-Type": "application/json" }, 19 body: JSON.stringify(credentials), 20 }); 21 22 if (!res.ok) { 23 const data = await res.json().catch(() => ({})); 24 throw new Error(data.error || "Invalid credentials"); 25 } 26 27 const json: unknown = await res.json(); 28 const result = loginResponseSchema.safeParse(json); 29 30 if (!result.success) { 31 throw new Error("Invalid response from authentication server"); 32 } 33 34 return result.data; 35 } 36 37 async function logout(token: string): Promise<{ success: boolean }> { 38 try { 39 await fetch(`${env.backendUrl}/auth/logout`, { 40 method: "POST", 41 headers: { 42 "Content-Type": "application/json", 43 Authorization: `Bearer ${token}`, 44 }, 45 }); 46 } catch { 47 // Backend unreachable — cookie deletion is the primary logout 48 } 49 50 return { success: true }; 51 }
8. Route Protection
Next.js 16 replaces middleware.ts with proxy.ts. It runs before page rendering, protects routes by checking for a valid token cookie using the centralized cookie name, skips API routes, and clears invalid cookies to prevent redirect loops. The matcher scopes it to /dashboard/:path*; add your own paths to protectedPaths.
1 // proxy.ts 2 import { NextRequest, NextResponse } from "next/server"; 3 import { verifyTokenSafe } from "@/server/auth/auth.jwt"; 4 import { AUTH_COOKIE_NAME } from "@/server/auth/auth.cookie"; 5 import { logger } from "@/server/logging/logger"; 6 7 const protectedPaths: string[] = []; 8 9 export async function proxy(request: NextRequest) { 10 const { pathname } = request.nextUrl; 11 12 const isProtected = protectedPaths.some((p) => pathname.startsWith(p)); 13 if (!isProtected) { 14 return NextResponse.next(); 15 } 16 17 if (pathname.startsWith("/api/")) { 18 return NextResponse.next(); 19 } 20 21 const token = request.cookies.get(AUTH_COOKIE_NAME)?.value; 22 23 if (!token) { 24 logger.debug("No token found, redirecting to login", { pathname }); 25 return NextResponse.redirect(new URL("/login", request.url)); 26 } 27 28 const result = await verifyTokenSafe(token); 29 30 if (!result.success) { 31 logger.debug("Token verification failed, redirecting to login", { 32 pathname, 33 error: result.error?.name, 34 }); 35 36 const response = NextResponse.redirect(new URL("/login", request.url)); 37 response.cookies.delete(AUTH_COOKIE_NAME); 38 return response; 39 } 40 41 return NextResponse.next(); 42 } 43 44 export const config = { 45 matcher: ["/dashboard/:path*"], 46 };
9. Error Normalization
All auth errors return structured responses with consistent format. The success field helps clients distinguish success from failure programmatically.
1 // Consistent response structure: 2 { "success": true, "user": { ... } } // Success 3 { "success": false, "error": "...", "code": "AUTH_..." } // Error 4 5 // Error codes: 6 AUTH_UNAUTHORIZED, AUTH_FORBIDDEN, AUTH_TOKEN_EXPIRED, 7 AUTH_INVALID_TOKEN, AUTH_INVALID_CREDENTIALS, AUTH_SERVER_ERROR
10. CSRF Protection
Since authentication uses cookies, CSRF (Cross-Site Request Forgery) is a concern. This implementation uses SameSite=lax cookies which provide baseline CSRF protection.
Mitigation Strategies
- SameSite cookies: The
laxsetting allows cookies to be sent with top-level navigations but blocks cross-site requests. - Double-submit cookie: For sensitive operations, generate a CSRF token and validate it in both a cookie and request header.
- Custom headers: Require
X-Requested-With: XMLHttpRequestfor state-changing API calls.
11. Dual Auth Strategy
The starter kit offers two independent authentication strategies that can be used separately or together:
NextAuth (OAuth Providers)
Use NextAuth.js (Auth.js) for social login — Google, GitHub, etc. Session management is handled by NextAuth. Ideal when you want users to sign in with existing accounts and don't need a custom backend.
Custom JWT (Email/Password)
Use the BFF pattern with your own backend for email/password auth. Your backend issues JWTs, Next.js stores them in httpOnly cookies and verifies them with jose. Ideal when you have a custom backend with user management.
Choosing a Strategy
- OAuth only: Remove the custom JWT files (
auth.jwt.ts,auth.cookie.ts,auth.guard.ts) and use NextAuth middleware. - Custom JWT only: Remove the NextAuth config and use the BFF auth files as-is.
- Both: Keep both — NextAuth handles OAuth, custom JWT handles email/password. The middleware checks both session types.
12. Security Awareness
Why BFF Reduces Attack Surface
The backend never sees the user's browser directly. Next.js holds the JWT in an httpOnly cookie, so XSS attacks cannot steal the token. The backend only communicates with Next.js, not with client browsers.
JWT Algorithm Restriction
Always explicitly specify the allowed algorithm (e.g., HS256). Never trust the algorithm declared in the token header — attackers could try to switch to "none" or use asymmetric algorithms.
Key Rotation
Rotate JWT secrets periodically. In production, store secrets in AWS Secrets Manager, HashiCorp Vault, or similar. The shared secret must match between Next.js and your backend.
Refresh Token Strategy
Short-lived access tokens (15-60 min) reduce exposure if compromised. Refresh tokens allow clients to obtain new access without re-entering credentials. Implement token rotation (invalidate old refresh token on use) for enhanced security.
13. Production Awareness
Stateless JWT in Serverless
JWT verification requires no database calls — the token contains all claims. This is ideal for serverless (Vercel, Lambda) where each request may hit a different instance.
Horizontal Scaling
Since JWT verification is stateless, any server instance can validate any token. No session affinity required — add or remove instances without authentication issues.
Token Revocation Limitations
JWTs are valid until expiration. To revoke access immediately, implement a blocklist (Redis) or use short-lived tokens with refresh token rotation. For most apps, short expiry (15-60 min) is sufficient.
Audit Logging
Log failed login attempts, privilege escalation, and token validation failures. Use structured logs for querying in your SIEM.
14. Environment Variables
Generate a secure secret using: openssl rand -base64 32
1 # .env.local 2 JWT_SECRET=your-32-character-minimum-secret-key 3 BACKEND_URL=http://localhost:3030
Tip: The login page sits outside the (dashboard)/ route group so it doesn't include the sidebar. Protected pages inside (dashboard)/ get the full dashboard layout with sidebar navigation.