Back to blog
5 min read215 views

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.

Next.jsSupabaseAppSecAuthenticationRBACTypeScript

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).*

Rate this article

5.0 / 5.0 (19 votes)

Was this helpful?