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 header

To wire it up

  • Set JWT_SECRET (32+ chars) and BACKEND_URL in .env.local — see §2.
  • Add routes to protect in the protectedPaths array in proxy.ts — see §8.
  • Define roles in the ROLES constant (§5); tune the cookie maxAge in §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
2let validated = false;
3const errors: string[] = [];
4
5function 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
25export 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
2import { jwtVerify } from "jose";
3import { JWTExpired } from "jose/errors";
4import { z } from "zod";
5import { env } from "@/server/env";
6import { ROLES } from "./types/auth.types";
7import type { AuthPayload } from "./types/auth.types";
8import { TokenExpiredError, InvalidTokenError } from "./types/auth.types";
9
10const 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
19export 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
2import { env } from "@/server/env";
3
4export const AUTH_COOKIE_NAME = env.isProduction ? "__Host-token" : "token";
5
6export 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
2export const ROLES = ["admin", "user"] as const;
3export type Role = (typeof ROLES)[number];
4
5// server/auth/auth.guard.ts
6import { AUTH_COOKIE_NAME } from "./auth.cookie";
7import type { Role } from "./types/auth.types";
8
9export 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
15export 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
2import { z } from "zod";
3import { AUTH_COOKIE_NAME, AUTH_COOKIE_OPTIONS } from "@/server/auth/auth.cookie";
4import { authService } from "@/features/auth/services/auth.service";
5import { toApiError, AUTH_ERROR_CODES } from "@/server/auth/types/auth.types";
6
7// TODO: Add rate limiting (Redis or in-memory store)
8
9const 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
14export 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
2import { z } from "zod";
3import { ROLES } from "@/server/auth/types/auth.types";
4
5const 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
15async 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
37async 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
2import { NextRequest, NextResponse } from "next/server";
3import { verifyTokenSafe } from "@/server/auth/auth.jwt";
4import { AUTH_COOKIE_NAME } from "@/server/auth/auth.cookie";
5import { logger } from "@/server/logging/logger";
6
7const protectedPaths: string[] = [];
8
9export 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
44export 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:
6AUTH_UNAUTHORIZED, AUTH_FORBIDDEN, AUTH_TOKEN_EXPIRED,
7AUTH_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 lax setting 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: XMLHttpRequest for 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
2JWT_SECRET=your-32-character-minimum-secret-key
3BACKEND_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.

Built with Next.js, deployed from a couch in Indonesia.

© 2025 Erik Yuntantyo · turmerrific