Implementing Team RBAC with Supabase Auth + RLS — From JWT Claims Design to Role-Based Access Policies
When building a SaaS product, there's a wall you inevitably hit: "Only admins on this team should be able to delete documents, and viewers should only be able to read — how do I implement that?" At first, you end up stuffing conditions like if (user.role === 'admin') all over your server code, and as they multiply, permission logic ends up scattered across controllers, middleware, and services until maintenance becomes a nightmare.
Supabase offers a way to solve this problem at the database level. By combining PostgreSQL's Row Level Security (RLS) with JWT custom claims, you can enforce rules like "only people with this role can see this row" at the table level — with almost no permission checks in server code. Whether you query from the client SDK or from a server Edge Function, the same policies are automatically applied.
This post walks through each step with working code — from designing JWT custom claims to structuring the team membership table, branching RLS policies by role, and performance optimization patterns. By the end, you'll have a working skeleton of a team permission system.
Core Concepts
How the Three Layers Fit Together
A team-based permission system operates across three layers working in concert.
[1] Custom Access Token Hook (PostgreSQL function)
→ At login, queries team_members → injects role array into JWT app_metadata
[2] JWT Claims (app_metadata.team_roles)
→ Already present inside the token as [{team_id, role}, ...]
[3] Row Level Security (RLS)
→ On each query, checks role via auth.jwt() / auth.uid() → applies row-level filterauth.uid() and auth.jwt() are built-in helper functions provided by Supabase. They parse the JWT included in the current request to extract the user ID and claims, and can be used freely within RLS policies.
The key in this flow is the second layer. If role information is already embedded in the token, there's no need to ask the DB "what role is this person?" on every query.
app_metadata vs user_metadata — Confusing These Creates a Security Vulnerability
I didn't take the difference between these two seriously at first, but using them incorrectly can lead to privilege escalation vulnerabilities.
Key distinction:
raw_app_meta_datacan only be modified by the server, making it safe to use for authorization. In contrast,raw_user_meta_datacan be modified directly by the client, so it must never be used for access control.
raw_app_meta_data → server-only modification → safe for Authorization ✅
raw_user_meta_data → user can modify directly → do NOT use for authorization ❌When reading roles in RLS policies, always write it like this:
(auth.jwt() -> 'app_metadata' ->> 'role')Here, the -> operator returns a JSONB value as-is, while ->> returns it as text. Since we need to compare roles as strings, we must use ->>.
Practical Implementation
Step 1: Schema Design
You'll need three tables to start: teams, team membership (including roles), and team-owned resources.
-- Teams table
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Team membership table (including roles)
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('admin', 'member', 'viewer')) NOT NULL,
UNIQUE(team_id, user_id)
);
-- Indexes to prevent Hook timeouts (required)
CREATE INDEX ON team_members(user_id);
CREATE INDEX ON team_members(team_id, user_id);
-- Example team-owned resource
CREATE TABLE team_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_by UUID REFERENCES auth.users(id)
);The UNIQUE(team_id, user_id) constraint is important — it prevents a single person from being registered with two different roles in the same team. The indexes should always be created alongside the table to speed up user_id lookups when the Custom Access Token Hook runs.
| Table | Role |
|---|---|
teams |
Basic team information |
team_members |
Links user ↔ team ↔ role. The authority basis for RLS policies |
team_documents |
Example of a team-owned resource. team_id identifies the owning team |
Step 2: Injecting Roles into JWT via Hook
Once you write this function and register it in the dashboard, all subsequently issued JWTs will automatically include team role information.
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB
LANGUAGE plpgsql
STABLE -- Declares result is immutable within the same transaction. Allows the planner to optimize call count
AS $$
DECLARE
claims JSONB;
user_id UUID;
team_roles JSONB;
BEGIN
user_id := (event->>'user_id')::UUID;
-- Query all team roles for the user as an array
SELECT jsonb_agg(
jsonb_build_object('team_id', team_id::TEXT, 'role', role)
)
INTO team_roles
FROM team_members
WHERE team_members.user_id = user_id;
claims := event->'claims';
-- Insert team roles array into app_metadata (empty array for users with no teams)
claims := jsonb_set(
claims,
'{app_metadata, team_roles}',
COALESCE(team_roles, '[]'::JSONB)
);
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- Grant execute permission to supabase_auth_admin so the Hook can run
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;If you omit the GRANT statement, the Hook won't work even after being registered. In the Supabase dashboard, go to Authentication → Hooks → Custom Access Token Hook and connect the function above. After logging in, it's a good idea to decode the JWT and verify that the
app_metadata.team_rolesarray is present.
Decoding a JWT issued after this will reveal a structure like this:
{
"app_metadata": {
"team_roles": [
{ "team_id": "uuid-a", "role": "admin" },
{ "team_id": "uuid-b", "role": "viewer" }
]
}
}Step 3: Preventing Circular References with a SECURITY DEFINER Helper Function
Honestly, when I first started writing RLS, I spent a long time wondering "why isn't this query returning?" — and it turned out to be this circular reference problem. If RLS is applied to team_members and you query team_members again inside a policy, you get an infinite loop.
Using a SECURITY DEFINER function solves this. With this attribute, the function runs with the permissions of its owner (postgres) rather than the caller, bypassing RLS.
CREATE OR REPLACE FUNCTION get_user_team_role(check_team_id UUID)
RETURNS TEXT
LANGUAGE sql
STABLE -- Allows result caching on repeated calls within the same transaction
SECURITY DEFINER
SET search_path = public -- Prevents security vulnerabilities via search_path manipulation
AS $$
SELECT role
FROM team_members
WHERE team_id = check_team_id
AND user_id = auth.uid()
LIMIT 1;
$$;
SET search_path = publicis required for SECURITY DEFINER functions. Omitting it can create a vulnerability where an attacker manipulates the search_path to execute a different function from another schema.
Note that if you want to apply RLS to the team_members table itself, you can attach a SELECT policy that uses this function as a bypass path, like so:
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
-- Users can only query their own team_members rows (SECURITY DEFINER function internals are unaffected by this policy)
CREATE POLICY "team_members_select_own"
ON team_members FOR SELECT
USING (user_id = auth.uid());Step 4: Branching RLS Policies by Role
With the helper function in place, you can now attach policies that branch SELECT / INSERT / UPDATE / DELETE based on role.
If you don't run
ALTER TABLE ... ENABLE ROW LEVEL SECURITYfirst, none of the policies below will have any effect. RLS is disabled on tables by default.
-- Enable RLS (this comes first!)
ALTER TABLE team_documents ENABLE ROW LEVEL SECURITY;
-- Viewer and above: can read documents from their team
-- get_user_team_role() is called per row, so it can impact performance for large result sets
CREATE POLICY "team_docs_select"
ON team_documents FOR SELECT
USING (
get_user_team_role(team_id) IN ('admin', 'member', 'viewer')
);
-- Member and above: can create documents
CREATE POLICY "team_docs_insert"
ON team_documents FOR INSERT
WITH CHECK (
get_user_team_role(team_id) IN ('admin', 'member')
AND created_by = auth.uid()
);
-- Member: can only update documents they created
CREATE POLICY "team_docs_update_member"
ON team_documents FOR UPDATE
USING (
get_user_team_role(team_id) = 'member'
AND created_by = auth.uid()
);
-- Admin: can update/delete all documents in the team
CREATE POLICY "team_docs_update_admin"
ON team_documents FOR UPDATE
USING (get_user_team_role(team_id) = 'admin');
CREATE POLICY "team_docs_delete_admin"
ON team_documents FOR DELETE
USING (get_user_team_role(team_id) = 'admin');When multiple UPDATE policies are attached to the same table, SELECT conditions are combined with OR. That is, satisfying either team_docs_update_member or team_docs_update_admin is sufficient to allow the UPDATE.
Step 5: High-Performance Policies Based on JWT Claims
If roles don't change frequently and JWT size isn't a concern, you can write policies using only JWT claims — with no DB lookups at all. Eliminating the get_user_team_role() function call entirely provides a significant boost to query performance.
CREATE POLICY "team_docs_select_from_jwt"
ON team_documents FOR SELECT
USING (
EXISTS (
SELECT 1
FROM jsonb_array_elements(
(auth.jwt() -> 'app_metadata' -> 'team_roles')
) AS tr
-- Cast team_id to TEXT to unify types with the string team_id in the JWT
WHERE (tr->>'team_id') = team_id::TEXT
AND (tr->>'role') IN ('admin', 'member', 'viewer')
)
);According to community reports, switching to this approach has reduced query response times from hundreds of milliseconds to single-digit milliseconds in some cases. The tradeoff, however, is that role changes are not immediately reflected until the token expires.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| DB-level enforced security | Even if application code is bypassed, DB policies always apply |
| Consistency | The same policies apply across client SDK, server Edge Functions, and direct dashboard queries |
| Simplified code | No need to write separate permission-checking middleware in server code |
| Performance (JWT approach) | JWT claims-based authorization requires no additional DB lookups |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Role changes not immediately reflected | JWTs retain old roles until expiration (default 1 hour) | Shorten token expiry + force logout on role changes |
| JWT size issues | Users in many teams will have large team_roles arrays, bloating the token |
Include only the currently active team, or fall back to DB lookup |
| Hook timeouts | PostgreSQL Hooks have ~2 second limits, HTTP Hooks ~5 seconds | Index optimization on role lookup queries is essential |
| Circular references | Querying team_members within a policy when RLS is applied to it causes infinite loops |
Use SECURITY DEFINER function as a workaround |
| Debugging difficulty | Hard to identify which policy is blocking a request | Use EXPLAIN ANALYZE after SET LOCAL role = authenticated |
RLS Policy Debugging Tip: In the Supabase SQL editor, you can simulate a specific user with
SET LOCAL role = authenticated; SET LOCAL request.jwt.claims = '{"sub": "user-uuid", "app_metadata": {...}}';and directly test queries.
Architecture Decision Guide
Team × role combinations are simple → JWT claims approach (high performance, simple implementation)
Roles change frequently or must reflect immediately → DB lookup approach (SECURITY DEFINER function)
Both approaches mixed → Simple flags in JWT, fine-grained permissions via DB lookupThe Most Common Real-World Mistakes
-
Storing roles in
user_metadata: This is the most common mistake. Becauseuser_metadatacan be modified directly by the client, it's directly exposed to privilege escalation attacks. Role information must always be written toapp_metadatafrom the server side only. -
Applying RLS to
team_membersthen querying it directly inside a policy: Querying an RLS-enabled table within its own policy causes infinite recursion. You must wrap the lookup in aSECURITY DEFINERfunction. -
Not refreshing the token after a role change: If someone is demoted from admin to viewer but their existing token is still live, they'll continue to operate with admin privileges. It's good practice to implement logic that force-expires the affected user's session whenever a role change event occurs.
Closing Thoughts
The Supabase Auth + RLS combination is the most reliable way to enforce a team-based permission system at the database level rather than in application code.
Checking these things before you start will save you a lot of headaches:
-
Before creating the schema: Verify that indexes (
user_id,team_id + user_id) were created alongsideteam_members, and that therolecolumn has aCHECKconstraint. -
After registering the Hook: Verify that GRANT was given to
supabase_auth_admin, and that after logging in, decoding the JWT showsapp_metadata.team_rolespresent. -
Before writing RLS policies: Verify that
ALTER TABLE ... ENABLE ROW LEVEL SECURITYwas run first, and thatSECURITY DEFINERfunctions haveSET search_path = publicattached.
The RLS policy AI prompt feature in the Supabase Dashboard (official docs) can help you quickly draft initial policies — worth checking out.
References
- Custom Claims & Role-based Access Control (RBAC) | Supabase Docs
- Custom Access Token Hook | Supabase Docs
- Row Level Security | Supabase Docs
- Auth Hooks | Supabase Docs
- JSON Web Token (JWT) | Supabase Docs
- Supabase RLS Best Practices: Production Patterns for Secure Multi-Tenant Apps | MakerKit
- Using Postgres RLS for a team invite system with Supabase | Boardshape Engineering
- Supabase Row Level Security in Production: Patterns That Actually Work | DEV Community
- Multi-Tenant Applications with RLS on Supabase | AntStack
- Role-Based Access Control (RBAC) in Next.js Supabase | MakerKit Docs
- Supabase MVP Architecture in 2026: Practical Patterns | Valtorian