Role-Based Access Control (RBAC) in Next.js App Router with Supabase Auth
A complete architecture guide to enforcing granular Role-Based Access Control (Admin, Editor, Viewer) across Next.js 14 Server Components, Route Handlers, and PostgreSQL policies.
Why Client-Side Role Checks Are a Security Vulnerability
In modern Single-Page Applications, checking user permissions strictly on the frontend (e.g. if (user.role === 'admin') showAdminPanel()) is trivial to bypass by modifying JavaScript state in browser developer tools. True Role-Based Access Control (RBAC) must be enforced at three independent layers:
1. **Database Layer**: PostgreSQL Row Level Security (RLS) with custom role claims.
2. **Edge Middleware Layer**: Next.js middleware.ts routing guards.
3. **Server Component Layer**: Server-side data fetching validation with HMAC or JWT token verification.
---
1. Database Schema with Custom User Roles in Supabase
```sql -- Create custom roles enum CREATE TYPE public.app_role AS ENUM ('super_admin', 'org_admin', 'editor', 'viewer');
-- Create user_roles table CREATE TABLE public.user_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, role public.app_role NOT NULL DEFAULT 'viewer', created_at TIMESTAMPTZ DEFAULT now(), UNIQUE (user_id, role) );
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
-- Security Definier Function to inspect user role without RLS recursion CREATE OR REPLACE FUNCTION public.has_role(required_role public.app_role) RETURNS BOOLEAN AS $$ BEGIN RETURN EXISTS ( SELECT 1 FROM public.user_roles WHERE user_id = auth.uid() AND role = required_role ); END; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- RLS Policy on restricted admin table CREATE POLICY "Only Admins can modify settings" ON public.site_settings FOR ALL TO authenticated USING (public.has_role('super_admin') OR public.has_role('org_admin')); ```
---
2. Next.js 14 Edge Middleware Authorization Guard
```typescript // middleware.ts import { createServerClient } from '@supabase/ssr'; import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(req: NextRequest) { const res = NextResponse.next(); const pathname = req.nextUrl.pathname;
// Only protect /admin routes if (!pathname.startsWith('/admin')) { return res; }
const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name) { return req.cookies.get(name)?.value; }, set(name, value, options) { res.cookies.set({ name, value, ...options }); }, remove(name, options) { res.cookies.set({ name, value: '', ...options }); }, }, } );
const { data: { user } } = await supabase.auth.getUser();
if (!user) { const loginUrl = new URL('/login', req.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); }
// Query user role const { data: userRole } = await supabase .from('user_roles') .select('role') .eq('user_id', user.id) .single();
if (!userRole || (userRole.role !== 'super_admin' && userRole.role !== 'org_admin')) { return NextResponse.redirect(new URL('/unauthorized', req.url)); }
return res; }
export const config = { matcher: ['/admin/:path*'], }; ```
---
3. Key Takeaways
* **Defense in Depth**: Middleware protects the URL route, but PostgreSQL RLS protects the actual data even if middleware is bypassed.
* **Avoid RLS Recursion**: Always write role check functions with SECURITY DEFINER so the function itself does not trigger infinite recursive RLS calls.
* **Log Security Violations**: Unauthenticated attempts against /admin endpoints should be piped to monitoring telemetry.
---
*Written by Abdul Nabi — Full-Stack Developer & AppSec Engineer. Explore more on [aiwithab.site](https://www.aiwithab.site).*