Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Protect Admin Routes with Supabase

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.

What you'll learn

  • How to store and retrieve user roles in Supabase
  • How to build client-side route guards that check for admin roles
  • How to write RLS policies that restrict data access by role
  • How to implement server-side auth checks for admin API routes
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner9 min read15-20 minSupabase (all plans), @supabase/supabase-js v2+, Next.js / ReactMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1-- Add role column to profiles table
2ALTER TABLE public.profiles
3ADD COLUMN role TEXT NOT NULL DEFAULT 'user'
4CHECK (role IN ('user', 'admin', 'moderator'));
5
6-- Create an index for role-based queries
7CREATE INDEX idx_profiles_role ON profiles USING btree (role);
8
9-- Promote a user to admin
10UPDATE profiles SET role = 'admin' WHERE id = 'user-uuid-here';
11
12-- Create a function to check if current user is admin
13CREATE OR REPLACE FUNCTION public.is_admin()
14RETURNS BOOLEAN
15LANGUAGE sql
16SECURITY DEFINER
17SET search_path = ''
18AS $$
19 SELECT EXISTS (
20 SELECT 1 FROM public.profiles
21 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.

2

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.

typescript
1-- Admin-only table: only admins can read and write
2CREATE 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);
8
9ALTER TABLE public.admin_settings ENABLE ROW LEVEL SECURITY;
10
11-- Only admins can SELECT
12CREATE POLICY "Only admins can read settings"
13ON public.admin_settings FOR SELECT
14TO authenticated
15USING (public.is_admin());
16
17-- Only admins can INSERT/UPDATE/DELETE
18CREATE POLICY "Only admins can modify settings"
19ON public.admin_settings FOR ALL
20TO authenticated
21USING (public.is_admin())
22WITH CHECK (public.is_admin());
23
24-- Regular table with admin override: users see own data, admins see all
25CREATE POLICY "Users see own data, admins see all"
26ON public.orders FOR SELECT
27TO authenticated
28USING (
29 (SELECT auth.uid()) = user_id
30 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.

3

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.

typescript
1import { useEffect, useState } from 'react';
2import { createClient } from '@supabase/supabase-js';
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7);
8
9interface AdminGuardProps {
10 children: React.ReactNode;
11 fallback?: React.ReactNode;
12}
13
14export function AdminGuard({ children, fallback }: AdminGuardProps) {
15 const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
16
17 useEffect(() => {
18 async function checkAdmin() {
19 const { data: { user } } = await supabase.auth.getUser();
20
21 if (!user) {
22 window.location.href = '/login';
23 return;
24 }
25
26 const { data: profile } = await supabase
27 .from('profiles')
28 .select('role')
29 .eq('id', user.id)
30 .single();
31
32 if (profile?.role === 'admin') {
33 setIsAdmin(true);
34 } else {
35 setIsAdmin(false);
36 window.location.href = '/';
37 }
38 }
39
40 checkAdmin();
41 }, []);
42
43 if (isAdmin === null) return fallback || <div>Loading...</div>;
44 if (!isAdmin) return null;
45
46 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.

4

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.

typescript
1// Next.js App Router: Server-side admin check
2// app/api/admin/settings/route.ts
3import { createClient } from '@/lib/supabase/server';
4import { NextResponse } from 'next/server';
5
6export async function GET() {
7 const supabase = await createClient();
8
9 // Verify the user is authenticated
10 const { data: { user }, error: authError } = await supabase.auth.getUser();
11 if (authError || !user) {
12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
13 }
14
15 // Check admin role
16 const { data: profile } = await supabase
17 .from('profiles')
18 .select('role')
19 .eq('id', user.id)
20 .single();
21
22 if (profile?.role !== 'admin') {
23 return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
24 }
25
26 // Process admin request
27 const { data: settings } = await supabase
28 .from('admin_settings')
29 .select('*');
30
31 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.

5

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.

typescript
1-- Create a trigger that adds the role to JWT claims on login
2CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
3RETURNS JSONB
4LANGUAGE plpgsql
5AS $$
6DECLARE
7 claims JSONB;
8 user_role TEXT;
9BEGIN
10 SELECT role INTO user_role
11 FROM public.profiles
12 WHERE id = (event->>'user_id')::UUID;
13
14 claims := event->'claims';
15 claims := jsonb_set(claims, '{user_role}', to_jsonb(COALESCE(user_role, 'user')));
16
17 event := jsonb_set(event, '{claims}', claims);
18 RETURN event;
19END;
20$$;
21
22-- RLS policy using JWT claims (faster, no extra query)
23CREATE POLICY "Admins can access admin table via JWT"
24ON public.admin_settings FOR ALL
25TO authenticated
26USING (
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

admin-route-protection.ts
1// Complete admin route protection for Supabase + React
2// Client-side guard with server-side RLS enforcement
3
4import { createClient } from '@supabase/supabase-js';
5import { useEffect, useState, createContext, useContext } from 'react';
6
7const supabase = createClient(
8 process.env.NEXT_PUBLIC_SUPABASE_URL!,
9 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
10);
11
12// Types
13type UserRole = 'user' | 'admin' | 'moderator';
14
15interface AuthContextType {
16 user: { id: string; email: string; role: UserRole } | null;
17 isAdmin: boolean;
18 loading: boolean;
19}
20
21// Auth context for global role access
22const AuthContext = createContext<AuthContextType>({
23 user: null,
24 isAdmin: false,
25 loading: true,
26});
27
28export function useAuth() {
29 return useContext(AuthContext);
30}
31
32// Auth provider that fetches user + role on mount
33export function AuthProvider({ children }: { children: React.ReactNode }) {
34 const [auth, setAuth] = useState<AuthContextType>({
35 user: null,
36 isAdmin: false,
37 loading: true,
38 });
39
40 useEffect(() => {
41 async function loadUser() {
42 const { data: { user } } = await supabase.auth.getUser();
43
44 if (!user) {
45 setAuth({ user: null, isAdmin: false, loading: false });
46 return;
47 }
48
49 const { data: profile } = await supabase
50 .from('profiles')
51 .select('role')
52 .eq('id', user.id)
53 .single();
54
55 const role = (profile?.role as UserRole) || 'user';
56
57 setAuth({
58 user: { id: user.id, email: user.email!, role },
59 isAdmin: role === 'admin',
60 loading: false,
61 });
62 }
63
64 loadUser();
65 }, []);
66
67 return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
68}
69
70// Admin guard component
71export function AdminGuard({ children }: { children: React.ReactNode }) {
72 const { user, isAdmin, loading } = useAuth();
73
74 if (loading) return <div>Checking permissions...</div>;
75 if (!user) { window.location.href = '/login'; return null; }
76 if (!isAdmin) { window.location.href = '/'; return null; }
77
78 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.