
Amit Hariyale
Full Stack Web Developer, Gigawave
Full Stack Web Developer, Gigawave
Implementing robust access control is crucial for any SaaS application. When using Next.js with a custom backend, developers often face the challenge of integrating JWT-based authentication with middleware for role-based access control (RBAC). This guide walks through an effective solution for managing page-level permissions using Next.js middleware.
Our backend team implemented JWTs with a role claim in the payload:
1interface JwtPayload {
2 userId: string;
3 role: "USER" | "ADMIN" | "MODERATOR";
4 // ...other claims
5}
The middleware handles authentication and authorization before requests reach your pages:
1import { NextResponse } from 'next/server';
2import type { NextRequest } from 'next/server';
3import { verifyToken } from '@/lib/auth'; // Custom JWT verifier
4
5// Role-based route configuration
6const roleRoutes: Record<string, string[]> = {
7 ADMIN: ['/dashboard', '/admin', '/settings'],
8 MODERATOR: ['/dashboard', '/mod-tools'],
9 USER: ['/dashboard'],
10};
11
12export async function middleware(req: NextRequest) {
13 const token = req.cookies.get('auth-token')?.value;
14 const { pathname } = req.nextUrl;
15
16 // 1. Redirect unauthenticated users
17 if (!token) {
18 return NextResponse.redirect(new URL('/login', req.url));
19 }
20
21 // 2. Verify JWT token
22 const payload = await verifyToken(token);
23 if (!payload) {
24 return NextResponse.redirect(new URL('/login', req.url));
25 }
26
27 // 3. Check if path requires specific role
28 const requiredRole = Object.entries(roleRoutes).find(([_, paths]) =>
29 paths.some(path => pathname.startsWith(path))
30 )?.[0];
31
32 // 4. Grant access if no specific role required
33 if (!requiredRole) return NextResponse.next();
34
35 // 5. Verify user has required role
36 if (payload.role !== requiredRole) {
37 return NextResponse.redirect(new URL('/unauthorized', req.url));
38 }
39
40 return NextResponse.next();
41}
42
43export const config = {
44 matcher: [
45 '/dashboard/:path*',
46 '/admin/:path*',
47 '/mod-tools/:path*',
48 '/settings/:path*'
49 ],
50};
Our custom verifyToken
function:
1import jwt from '@tsndr/cloudflare-worker-jwt'; // Lightweight JWT verifier
2
3export async function verifyToken(token: string) {
4 try {
5 const isValid = await jwt.verify(token, process.env.JWT_SECRET!);
6 if (!isValid) return null;
7
8 const { payload } = jwt.decode(token);
9 return payload as JwtPayload;
10 } catch (error) {
11 console.error('Token verification failed:', error);
12 return null;
13 }
14}
Defining role/route relationships:
1const roleRoutes = {
2 ADMIN: ['/dashboard', '/admin', '/settings'],
3 MODERATOR: ['/dashboard', '/mod-tools'],
4 USER: ['/dashboard'],
5};
1const roleHierarchy = {
2 ADMIN: ['ADMIN', 'MODERATOR', 'USER'],
3 MODERATOR: ['MODERATOR', 'USER'],
4 USER: ['USER']
5};
6
7// In middleware:
8if (!roleHierarchy[payload.role].includes(requiredRole)) {
9 return NextResponse.redirect('/unauthorized');
10}
Always verify roles in API routes too:
1import { verifyToken } from '@/lib/auth';
2
3export async function GET(req: Request) {
4 const token = req.headers.get('Authorization')?.split(' ')[1];
5
6 if (!token) {
7 return new Response('Unauthorized', { status: 401 });
8 }
9
10 const payload = await verifyToken(token);
11
12 if (payload?.role !== 'ADMIN') {
13 return new Response('Forbidden', { status: 403 });
14 }
15
16 // Admin-only logic
17}
Implementing RBAC with a custom backend required careful coordination between frontend and backend teams, but using Next.js middleware provided a clean, centralized solution. The key was establishing clear contracts for JWT structure and implementing lightweight verification for edge runtime.
Remember: Middleware is your first security layer, but always validate permissions in your backend too!