To protect admin routes with Supabase, combine client-side route guards with server-side role checks. Store user roles in a profiles table or JWT custom claims, check the role with getUser() before rendering admin pages, and enforce the same check in RLS policies so that even direct API calls are blocked for non-admin users. Never rely on client-side checks alone — the server-side RLS policy is the real security boundary.
Protecting Admin Pages and API Endpoints with Supabase
Admin route protection requires multiple layers: a client-side guard that redirects non-admin users, a server-side check that verifies the role before processing requests, and RLS policies that enforce access at the database level. This tutorial shows you how to implement all three layers using Supabase's auth system and Row Level Security, ensuring that admin-only data is secure even if someone bypasses the client-side checks.
Prerequisites
- A Supabase project with authentication configured
- A profiles table linked to auth.users
- The Supabase JS client installed and initialized
- Basic understanding of React routing or Next.js App Router
Step-by-step guide
Add a role column to the profiles table
Add a role column to the profiles table
The simplest way to manage roles in Supabase is to store them in a profiles table that references auth.users. Add a role column with a default value of 'user'. Admins can be promoted by updating this column. This approach is more flexible than JWT custom claims because you can change roles without requiring the user to re-authenticate.
1-- Add role column to profiles table2ALTER TABLE public.profiles3ADD COLUMN role TEXT NOT NULL DEFAULT 'user'4CHECK (role IN ('user', 'admin', 'moderator'));56-- Create an index for role-based queries7CREATE INDEX idx_profiles_role ON profiles USING btree (role);89-- Promote a user to admin10UPDATE profiles SET role = 'admin' WHERE id = 'user-uuid-here';1112-- Create a function to check if current user is admin13CREATE OR REPLACE FUNCTION public.is_admin()14RETURNS BOOLEAN15LANGUAGE sql16SECURITY DEFINER17SET search_path = ''18AS $$19 SELECT EXISTS (20 SELECT 1 FROM public.profiles21 WHERE id = (SELECT auth.uid())22 AND role = 'admin'23 );24$$;Expected result: The profiles table has a role column. You can query a user's role and promote users to admin via SQL.
Write RLS policies that restrict access by role
Write RLS policies that restrict access by role
Create RLS policies on admin-only tables that check the user's role from the profiles table. This is the most important security layer — RLS runs at the database level and cannot be bypassed by client-side code. Use the is_admin() function or a subquery to check the role. For tables that both regular users and admins can access, create separate policies for each role.
1-- Admin-only table: only admins can read and write2CREATE TABLE public.admin_settings (3 id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,4 key TEXT UNIQUE NOT NULL,5 value JSONB NOT NULL,6 updated_at TIMESTAMPTZ DEFAULT now()7);89ALTER TABLE public.admin_settings ENABLE ROW LEVEL SECURITY;1011-- Only admins can SELECT12CREATE POLICY "Only admins can read settings"13ON public.admin_settings FOR SELECT14TO authenticated15USING (public.is_admin());1617-- Only admins can INSERT/UPDATE/DELETE18CREATE POLICY "Only admins can modify settings"19ON public.admin_settings FOR ALL20TO authenticated21USING (public.is_admin())22WITH CHECK (public.is_admin());2324-- Regular table with admin override: users see own data, admins see all25CREATE POLICY "Users see own data, admins see all"26ON public.orders FOR SELECT27TO authenticated28USING (29 (SELECT auth.uid()) = user_id30 OR public.is_admin()31);Expected result: Non-admin users get empty results or permission errors when trying to access admin-only tables. Admin users have full access.
Build a client-side admin route guard in React
Build a client-side admin route guard in React
Create a wrapper component that checks the user's role before rendering admin pages. This provides a better user experience by redirecting non-admin users immediately instead of showing an empty page. Remember that this is a UX convenience — the real security is in the RLS policies. The guard fetches the user's role from the profiles table and redirects if they are not an admin.
1import { useEffect, useState } from 'react';2import { createClient } from '@supabase/supabase-js';34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!7);89interface AdminGuardProps {10 children: React.ReactNode;11 fallback?: React.ReactNode;12}1314export function AdminGuard({ children, fallback }: AdminGuardProps) {15 const [isAdmin, setIsAdmin] = useState<boolean | null>(null);1617 useEffect(() => {18 async function checkAdmin() {19 const { data: { user } } = await supabase.auth.getUser();2021 if (!user) {22 window.location.href = '/login';23 return;24 }2526 const { data: profile } = await supabase27 .from('profiles')28 .select('role')29 .eq('id', user.id)30 .single();3132 if (profile?.role === 'admin') {33 setIsAdmin(true);34 } else {35 setIsAdmin(false);36 window.location.href = '/';37 }38 }3940 checkAdmin();41 }, []);4243 if (isAdmin === null) return fallback || <div>Loading...</div>;44 if (!isAdmin) return null;4546 return <>{children}</>;47}Expected result: Non-admin users are redirected away from admin pages. Admin users see the protected content after the role check completes.
Implement server-side admin checks for API routes
Implement server-side admin checks for API routes
For Next.js API routes or server actions, verify the admin role on the server before processing the request. Use the @supabase/ssr package to create a server-side Supabase client that reads the session from cookies. Call getUser() to verify the JWT, then check the role from the profiles table. This prevents API abuse where someone calls admin endpoints directly without going through the UI.
1// Next.js App Router: Server-side admin check2// app/api/admin/settings/route.ts3import { createClient } from '@/lib/supabase/server';4import { NextResponse } from 'next/server';56export async function GET() {7 const supabase = await createClient();89 // Verify the user is authenticated10 const { data: { user }, error: authError } = await supabase.auth.getUser();11 if (authError || !user) {12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });13 }1415 // Check admin role16 const { data: profile } = await supabase17 .from('profiles')18 .select('role')19 .eq('id', user.id)20 .single();2122 if (profile?.role !== 'admin') {23 return NextResponse.json({ error: 'Forbidden' }, { status: 403 });24 }2526 // Process admin request27 const { data: settings } = await supabase28 .from('admin_settings')29 .select('*');3031 return NextResponse.json(settings);32}Expected result: API routes return 401 for unauthenticated users and 403 for non-admin users. Only admin users receive the requested data.
Optionally store roles in JWT custom claims for faster checks
Optionally store roles in JWT custom claims for faster checks
For performance-sensitive applications, you can store the user's role in the JWT custom claims using a database trigger. This avoids an extra query to the profiles table on every request because the role is embedded in the JWT. The tradeoff is that role changes require a token refresh to take effect. Use the auth.jwt() function in RLS policies to read the custom claim.
1-- Create a trigger that adds the role to JWT claims on login2CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)3RETURNS JSONB4LANGUAGE plpgsql5AS $$6DECLARE7 claims JSONB;8 user_role TEXT;9BEGIN10 SELECT role INTO user_role11 FROM public.profiles12 WHERE id = (event->>'user_id')::UUID;1314 claims := event->'claims';15 claims := jsonb_set(claims, '{user_role}', to_jsonb(COALESCE(user_role, 'user')));1617 event := jsonb_set(event, '{claims}', claims);18 RETURN event;19END;20$$;2122-- RLS policy using JWT claims (faster, no extra query)23CREATE POLICY "Admins can access admin table via JWT"24ON public.admin_settings FOR ALL25TO authenticated26USING (27 (SELECT auth.jwt() ->> 'user_role') = 'admin'28)29WITH CHECK (30 (SELECT auth.jwt() ->> 'user_role') = 'admin'31);Expected result: The user's role is embedded in their JWT, and RLS policies can check it without querying the profiles table.
Complete working example
1// Complete admin route protection for Supabase + React2// Client-side guard with server-side RLS enforcement34import { createClient } from '@supabase/supabase-js';5import { useEffect, useState, createContext, useContext } from 'react';67const supabase = createClient(8 process.env.NEXT_PUBLIC_SUPABASE_URL!,9 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!10);1112// Types13type UserRole = 'user' | 'admin' | 'moderator';1415interface AuthContextType {16 user: { id: string; email: string; role: UserRole } | null;17 isAdmin: boolean;18 loading: boolean;19}2021// Auth context for global role access22const AuthContext = createContext<AuthContextType>({23 user: null,24 isAdmin: false,25 loading: true,26});2728export function useAuth() {29 return useContext(AuthContext);30}3132// Auth provider that fetches user + role on mount33export function AuthProvider({ children }: { children: React.ReactNode }) {34 const [auth, setAuth] = useState<AuthContextType>({35 user: null,36 isAdmin: false,37 loading: true,38 });3940 useEffect(() => {41 async function loadUser() {42 const { data: { user } } = await supabase.auth.getUser();4344 if (!user) {45 setAuth({ user: null, isAdmin: false, loading: false });46 return;47 }4849 const { data: profile } = await supabase50 .from('profiles')51 .select('role')52 .eq('id', user.id)53 .single();5455 const role = (profile?.role as UserRole) || 'user';5657 setAuth({58 user: { id: user.id, email: user.email!, role },59 isAdmin: role === 'admin',60 loading: false,61 });62 }6364 loadUser();65 }, []);6667 return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;68}6970// Admin guard component71export function AdminGuard({ children }: { children: React.ReactNode }) {72 const { user, isAdmin, loading } = useAuth();7374 if (loading) return <div>Checking permissions...</div>;75 if (!user) { window.location.href = '/login'; return null; }76 if (!isAdmin) { window.location.href = '/'; return null; }7778 return <>{children}</>;79}Common mistakes when protecting Admin Routes with Supabase
Why it's a problem: Relying only on client-side route guards without RLS policies, allowing direct API access to admin data
How to avoid: Always enforce role checks in RLS policies. Client-side guards improve UX but are not a security boundary — anyone can call the API directly.
Why it's a problem: Using getSession() instead of getUser() for role verification, which reads from local storage and can be tampered with
How to avoid: Use getUser() for all auth checks, especially admin verification. It makes a server request to validate the JWT and is tamper-proof.
Why it's a problem: Storing roles only in JWT claims without a database fallback, making role changes delayed until token refresh
How to avoid: Use a profiles table as the source of truth for roles. JWT claims are optional for performance but should not be the only role storage.
Why it's a problem: Forgetting to add WITH CHECK on INSERT/UPDATE RLS policies for admin tables, allowing non-admins to write data
How to avoid: Always include both USING (for reading existing rows) and WITH CHECK (for validating new/updated rows) on admin-only policies.
Best practices
- Implement role checks at three levels: client-side guard, server-side route handler, and database RLS policies
- Use getUser() instead of getSession() for all admin verification to prevent JWT tampering
- Store roles in a profiles table with a CHECK constraint for allowed values
- Create a reusable is_admin() database function for consistent role checks across RLS policies
- Use SECURITY DEFINER on the is_admin() function so it can read profiles regardless of the caller's RLS permissions
- Wrap the is_admin() call in RLS policies with SELECT for per-statement caching
- Log admin actions to an audit table for accountability and compliance
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I need to protect admin pages in my Supabase + Next.js app. Show me how to add a role column to my profiles table, write RLS policies that restrict data to admins, build a React route guard component, and implement server-side role verification in Next.js API routes.
Set up admin route protection in Supabase with a role column on the profiles table, an is_admin() SQL function, RLS policies for an admin_settings table, and a React AdminGuard component that checks the user's role before rendering.
Frequently asked questions
Is a client-side route guard enough to protect admin pages?
No. Client-side guards are a UX convenience, not a security boundary. Anyone can call the Supabase API directly. You must enforce admin access in RLS policies, which run at the database level and cannot be bypassed.
Should I store roles in the profiles table or in JWT claims?
Store roles in the profiles table as the source of truth. Optionally, mirror the role into JWT claims using a custom access token hook for faster checks in RLS policies. The profiles table approach allows instant role changes without waiting for token refresh.
How do I promote a user to admin?
Update the role column in the profiles table: UPDATE profiles SET role = 'admin' WHERE id = 'user-uuid'. If using JWT claims, the user needs to re-authenticate or refresh their token for the change to take effect.
Can I have multiple admin roles like super_admin and editor?
Yes. Extend the CHECK constraint on the role column and update your is_admin() function to check for multiple roles. Or create separate database functions like is_super_admin() and is_editor() for more granular control.
What happens if an admin's role is revoked while they are logged in?
If roles are stored in the profiles table, the RLS policy check happens on every request, so revocation is immediate. If roles are in JWT claims, the revocation takes effect after the token refreshes (default: 1 hour).
Can RapidDev help implement role-based access control for my Supabase application?
Yes. RapidDev can design and implement complete RBAC systems in Supabase including role management, RLS policies, admin dashboards, audit logging, and multi-level permission hierarchies.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation