---
name: supabase-custom-claims-rbac
description: Use ao implementar Custom Claims via Custom Access Token Auth Hook para RBAC em Supabase…
---

# Supabase — Custom Claims & RBAC via Auth Hooks

## Quando usar

LLM carrega esta skill quando implementar **Role-Based Access Control (RBAC)** via Custom Claims no JWT. Pattern canônico Supabase v1.25.

Trigger phrases:

- "custom claims Supabase", "RBAC Supabase"
- "Custom Access Token Auth Hook", "auth hook RBAC"
- "user_role no JWT", "authorize() function"
- "role-based access control com auth hook"
- "como evitar JOIN em RLS policy" (custom claims é a alternativa moderna)

## Princípio canônico

RBAC em Supabase tem **3 mecanismos de delivery** dos claims:

1. **`app_metadata`** (skill `supabase-rls-policies` v1.23) — JSONB no JWT setado via service_role admin API. Simples mas limitado (não suporta enum types, sem normalização).
2. **Dedicated role table com helper function STABLE** (skill `rbac-permissions-matrix-supabase` v1.21) — `user_roles` + `private.has_role(role)` consultada em policies. Dinâmico mas faz JOIN custoso por query.
3. **Custom Claims via Custom Access Token Auth Hook** (v1.25 — **RECOMENDADO**) — `user_role` injetado no JWT durante geração do token via auth hook. Evita JOIN em policies (claim lido direto via `auth.jwt() ->> 'user_role'`). Eventually consistent (refresh TTL).

**Quando usar custom claims (v1.25):**

- ✅ RBAC com 2-10 roles fixos por user (admin, moderator, user, etc.)
- ✅ Permission matrix relativamente estática (mudanças em horas/dias, não segundos)
- ✅ Policies que precisam ser rápidas (sem JOIN custoso)
- ✅ Cliente front-end precisa saber o role (UI conditional rendering)

**Quando NÃO usar custom claims:**

- ❌ Permission mudada em real-time (segundos) — JWT freshness não cobre
- ❌ Permission depende de row context (ex: "can edit if owner of this row") — use RLS row-level com `auth.uid()`
- ❌ Multi-tenant com user em N orgs com role diferente em cada — custom claim é per-user, não per-org-context

Para multi-tenant complexo, combine: custom claim para role global (super_admin) + RLS hierárquica com `private.has_role(role, org_id)` para context-aware (skill `multi-tenant-rls-hierarchy`).

## Pattern canônico — 7 passos

### Passo 1: Enum types

Defina enum types Postgres para roles e permissions — tipo-seguro, refactorable, documentado.

```sql
-- enum de roles canônicos
create type public.app_role as enum ('admin', 'moderator', 'user');

-- enum de permissions canônicos (resource.action)
create type public.app_permission as enum (
  'channels.delete',
  'channels.create',
  'messages.delete',
  'messages.update',
  'users.ban'
);
```

**Caveat:** adicionar novo enum value exige migration `alter type public.app_permission add value 'novo.permission'` — não pode ser feito dentro de transação (Postgres limitation).

### Passo 2: Tabelas user_roles + role_permissions

```sql
-- USER → ROLE mapping (N:1 user pode ter múltiplos roles)
create table public.user_roles (
  id        bigint generated by default as identity primary key,
  user_id   uuid references auth.users on delete cascade not null,
  role      app_role not null,
  unique (user_id, role)
);
comment on table public.user_roles is 'Application roles for each user.';

-- ROLE → PERMISSION mapping (N:N role tem múltiplas permissions)
create table public.role_permissions (
  id           bigint generated by default as identity primary key,
  role         app_role not null,
  permission   app_permission not null,
  unique (role, permission)
);
comment on table public.role_permissions is 'Application permissions for each role.';
```

Seed inicial:

```sql
insert into public.role_permissions (role, permission)
values
  ('admin', 'channels.delete'),
  ('admin', 'channels.create'),
  ('admin', 'messages.delete'),
  ('admin', 'messages.update'),
  ('admin', 'users.ban'),
  ('moderator', 'messages.delete'),
  ('moderator', 'messages.update');
```

### Passo 3: Custom Access Token Auth Hook function

A função `custom_access_token_hook(event jsonb) returns jsonb` roda **antes** do JWT ser issued — recebe o event (com `user_id` + claims atuais) e devolve event modificado.

```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
  declare
    claims jsonb;
    user_role public.app_role;
  begin
    -- buscar role do user em user_roles
    select role into user_role
    from public.user_roles
    where user_id = (event->>'user_id')::uuid;

    claims := event->'claims';

    if user_role is not null then
      claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
    else
      claims := jsonb_set(claims, '{user_role}', 'null');
    end if;

    -- atualiza objeto claims no event original
    event := jsonb_set(event, '{claims}', claims);

    return event;
  end;
$$;
```

**Caveat para multi-role:** se user tem múltiplos roles em `user_roles`, esta versão pega o primeiro (`select role into user_role`). Para múltiplos roles no JWT, use:

```sql
-- variante multi-role: claim user_roles como array
select array_agg(role) into user_roles_array
from public.user_roles
where user_id = (event->>'user_id')::uuid;
-- ...
claims := jsonb_set(claims, '{user_roles}', to_jsonb(user_roles_array));
```

### Passo 4: Permissions canônicos para `supabase_auth_admin`

`supabase_auth_admin` é o Postgres role usado pelo Supabase Auth service ao invocar o hook. Precisa de permissions específicos:

```sql
-- 1. acesso ao schema public
grant usage on schema public to supabase_auth_admin;

-- 2. permissão de EXECUTE no hook function (apenas auth_admin pode invocar)
grant execute
  on function public.custom_access_token_hook
  to supabase_auth_admin;

-- 3. REVOKE EXECUTE de roles públicos (cliente NÃO pode chamar diretamente)
revoke execute
  on function public.custom_access_token_hook
  from authenticated, anon, public;

-- 4. acesso à tabela user_roles
grant all
  on table public.user_roles
  to supabase_auth_admin;

-- 5. REVOKE acesso público à user_roles (cliente NÃO pode mutar roles)
revoke all
  on table public.user_roles
  from authenticated, anon, public;

-- 6. RLS policy permitindo supabase_auth_admin ler user_roles
create policy "Allow auth admin to read user roles" on public.user_roles
  as permissive for select
  to supabase_auth_admin
  using (true);
```

**Por quê REVOKE EXECUTE do hook:** sem isso, qualquer cliente autenticado poderia chamar `custom_access_token_hook(...)` e potencialmente abusar.

**Por quê REVOKE ALL FROM authenticated em user_roles:** roles são metadado privilegiado — apenas admins (service_role) podem inserir/atualizar. Cliente não deve ter SELECT direto na tabela (consultar via claim do JWT).

### Passo 5: Habilitar o hook

**Dashboard (production):**

1. Acesse `Authentication > Hooks (Beta)` no Dashboard Supabase
2. Selecione "Custom Access Token" hook type
3. Dropdown: selecione `public.custom_access_token_hook` (função criada no Passo 3)
4. Save

**Local development:**

Em `supabase/config.toml`:

```toml
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"
```

Reinicie o Supabase local (`supabase stop && supabase start`).

### Passo 6: `authorize()` function

A função `authorize(permission app_permission) returns boolean` é o coração do RBAC — lê `user_role` do JWT e checa se o role tem a permission.

```sql
create or replace function public.authorize(
  requested_permission app_permission
)
returns boolean
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
  bind_permissions int;
  user_role public.app_role;
begin
  -- ler user_role do JWT (delivered via auth hook do Passo 3)
  select (auth.jwt() ->> 'user_role')::public.app_role into user_role;

  -- contar permissions matching
  select count(*)
  into bind_permissions
  from public.role_permissions
  where role_permissions.permission = requested_permission
    and role_permissions.role = user_role;

  return bind_permissions > 0;
end;
$$;
```

**Decisões canônicas:**

- `stable` — função consulta DB mas não muta; resultado é estável dentro de uma transação (Postgres pode cachear)
- `security definer` — roda com privilégios do owner (geralmente `postgres`) — necessário para acessar role_permissions sem RLS recursivo
- `set search_path = ''` — anti schema injection (cross-ref skill `supabase-database-functions`)

### Passo 7: RLS policies usando authorize()

Em vez de hard-code role em policy, use `authorize(permission)`:

```sql
-- ANTES (anti-pattern): hard-code role direto na policy
create policy "Allow admin delete channels" on public.channels for delete
to authenticated
using ((auth.jwt() ->> 'user_role') = 'admin');

-- DEPOIS (canônico v1.25): authorize() abstrai role → permission
create policy "Allow authorized delete access" on public.channels for delete
to authenticated
using ((SELECT authorize('channels.delete')));

create policy "Allow authorized delete access" on public.messages for delete
to authenticated
using ((SELECT authorize('messages.delete')));
```

**Vantagens canônicas:**

- Adicionar nova permission = INSERT em role_permissions (sem alterar policy)
- Mudar quem tem permission = UPDATE em role_permissions
- Policies ficam estáveis; matriz permissions evolui
- Reutilizável em N policies

**Por que `(SELECT authorize(...))` com wrapper:** caching (cross-ref REGRA #2 da skill `supabase-rls-policies` v1.23) — função roda 1 vez por query, não 1 vez por linha.

## Client-side — acessar custom claims

Auth hook só modifica o **access_token JWT**, não a auth response. Para acessar custom claims no cliente, decode o JWT:

```bash
npm install jwt-decode
```

```js
import { jwtDecode } from 'jwt-decode'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(URL, ANON_KEY)

// listener para mudanças de auth state (login, logout, refresh)
supabase.auth.onAuthStateChange(async (event, session) => {
  if (session) {
    const jwt = jwtDecode(session.access_token)
    const userRole = jwt.user_role  // 'admin' | 'moderator' | 'user' | null

    // usar userRole para UI conditional rendering
    if (userRole === 'admin') {
      // show admin panel
    }
  }
})
```

**Para Next.js v16 + SSR:** decode o JWT no server-side via middleware ou Server Component:

```ts
// app/middleware.ts ou Server Component
import { jwtDecode } from 'jwt-decode'

const { data: { session } } = await supabase.auth.getSession()
if (session) {
  const jwt = jwtDecode(session.access_token)
  const userRole = jwt.user_role
  // ...
}
```

**Para backend (Node.js/Python/etc):** use bibliotecas equivalentes:

- Node.js: `jsonwebtoken`, `express-jwt`, `koa-jwt`
- Python: `PyJWT`
- Dart: `dart_jsonwebtoken`
- .NET: `Microsoft.AspNetCore.Authentication.JwtBearer`

## ⚠ Caveat — JWT Freshness

Mudanças em `user_roles` **NÃO** refletem imediatamente no JWT do user — apenas após **token refresh** (default TTL 1h em Supabase).

**Cenários:**

| Situação | Comportamento |
|----------|---------------|
| Admin adiciona role moderator ao user X | User X continua com `user_role: null` até próximo refresh (até 1h) |
| Admin remove role admin do user Y | User Y continua com `user_role: admin` no JWT atual; após refresh, policies bloqueiam |
| Permission adicionada em role_permissions (admin → users.ban) | Reflete IMEDIATAMENTE — authorize() consulta DB cada query (stable mas re-executa em query nova) |

**Implicações:**

- Adicionar role: aceitável demora (user pode logout/login para forçar refresh)
- **Remover role: PROBLEMA** — user comprometido pode usar JWT antigo até expirar. Para invalidação imediata:

```ts
// force logout via admin API (server-side com service_role)
await supabase.auth.admin.signOut(userId)
// próximo request do user vai falhar; user precisa login novamente
```

- Mudança em permissions matrix (role_permissions): IMEDIATA — não precisa refresh

## Anti-patterns

### Anti-pattern 1: Esquecer GRANT EXECUTE ao supabase_auth_admin

**Errado:**
```sql
create or replace function public.custom_access_token_hook(event jsonb) returns jsonb ...;
-- esqueceu o GRANT EXECUTE TO supabase_auth_admin
```

**Por quê:** auth hook falha silenciosamente — JWT é issued mas SEM o claim `user_role`. Difícil de debug.

**Certo:** sempre incluir os 6 GRANTs/REVOKEs do Passo 4.

### Anti-pattern 2: Hardcode role em policy ao invés de authorize()

**Errado:**
```sql
create policy "admin_delete" on public.channels for delete to authenticated
using ((auth.jwt() ->> 'user_role') = 'admin');
```

**Por quê:** acoplamento policy ↔ role. Mudar quem pode deletar requer ALTER POLICY (DDL); com authorize(), basta UPDATE em role_permissions.

**Certo:** `using ((SELECT authorize('channels.delete')))` — policy estável, matriz evolui.

### Anti-pattern 3: Assumir JWT fresh sem invalidação

**Errado:**
```ts
// admin remove role admin do user X
await supabase.from('user_roles').delete().eq('user_id', xId).eq('role', 'admin')
// CRÍTICO: user X ainda tem JWT antigo válido por até 1h
```

**Por quê:** revogação de role não é imediata. Em casos de comprometimento, atacante usa o JWT antigo para abusar de privilégios admin.

**Certo:** sempre force logout após mudança crítica de role:
```ts
await supabase.from('user_roles').delete().eq('user_id', xId).eq('role', 'admin')
await supabase.auth.admin.signOut(xId)  // força refresh imediato
```

### Anti-pattern 4: Mutar app_metadata do cliente para mudar role

**Errado:**
```ts
// no cliente
await supabase.auth.updateUser({ data: { role: 'admin' } })
// user_metadata é editável pelo cliente — privilege escalation
```

**Por quê:** mesmo anti-pattern de `user_metadata` da skill `supabase-rls-policies` (REGRA #1). Pior — confunde "role via custom claim" com "role via user_metadata".

**Certo:** roles são mutados APENAS via INSERT em `public.user_roles` por backend (service_role) ou admin API. Cliente nunca toca em metadata de role.

### Anti-pattern 5: Auth hook que faz query custosa

**Errado:**
```sql
create or replace function public.custom_access_token_hook(event jsonb) returns jsonb ... as $$
begin
  -- query custosa em cada token issuance
  select * into user_data from public.users
    join public.organizations o on u.org_id = o.id
    join public.subscriptions s on o.id = s.org_id
    where u.id = (event->>'user_id')::uuid;
  -- ...
end;
$$;
```

**Por quê:** hook roda em **cada login + cada refresh**. Query lenta degrada latência de auth. Bom hook é < 10ms.

**Certo:** mantenha hook simples — query single table (user_roles), aggregate se necessário, sem JOINs. Se precisa de info complexa, materialize em coluna de user_roles (denormalize).

## Postgres Roles vs Custom Claims — distinção canônica (v1.26)

**Custom Claims** (v1.25) e **Postgres Roles** (v1.26) são conceitos **complementares**, não substitutos:

| | Custom Claims (v1.25) | Postgres Roles (v1.26) |
|---|---|---|
| Tipo de access | Application access (end-users) | System access (service accounts) |
| Identidade | JWT claim `user_role` | Postgres login |
| Quem usa | End-users via PostgREST | Cron jobs, BI tools, ETL, admin scripts |
| Granularidade | Per-permission via `authorize()` | Per schema/table/function/column |
| Dinamicidade | Eventually consistent (TTL refresh) | Estática (alterações via SQL DDL) |
| Exemplo | "User admin pode deletar messages" | "Cron job pode SELECT em todas tabelas para backup" |

**Combine quando ambos aplicam:**

- End-user com role `admin` (via custom claim) acessa endpoint que invoca função `private.org_analytics()` com SECURITY DEFINER (que tem GRANT EXECUTE apenas para Postgres role `analytics_reader`)
- Service account `metabase_reader` (Postgres role) acessa view `public.user_active_tasks` com `security_invoker=true` (que respeita RLS via auth.jwt() — mas auth.jwt() é null para Postgres role login, então policies precisam considerar service_role bypass)

**NÃO use Postgres roles para application access:**

- ❌ Criar role Postgres `app_admin` para gerenciar quem é admin no app
- ❌ Criar role Postgres por end-user
- ✅ Use RLS + Custom Claims via auth hook (pattern v1.25)

**NÃO use Custom Claims para system access:**

- ❌ Service account cron job autenticando via JWT customizado
- ❌ BI tool authenticando via auth hook
- ✅ Use Postgres role dedicado (pattern v1.26)

Padrão completo de Postgres roles em [`supabase-postgres-roles`](../supabase-postgres-roles/SKILL.md) (v1.26).

## Cross-suite integration (v1.25)

Esta skill é base para o agent novo `supabase-rbac-implementer` (Phase 139) — recebe spec (roles + permissions matrix) via `Task()` e materializa setup completo. Pattern de handoff cooperativo herdado de v1.23/v1.24.

Para multi-tenant com role por org, **combine** custom claim (role global) + helper function PG (`private.has_role(role, org_id)` em RLS hierárquica — skill `multi-tenant-rls-hierarchy` v1.21). Custom claim sozinho não cobre context-aware multi-tenant.

## Ver também

- [supabase-rbac-implementer](../../agents/supabase-rbac-implementer.md) (v1.25) — canonical materializer
- [supabase-rls-policies](../supabase-rls-policies/SKILL.md) (v1.23) — section "RBAC via Custom Claims + authorize() function (v1.25)"
- [supabase-rls-defense-in-depth](../supabase-rls-defense-in-depth/SKILL.md) (v1.24) — Camada 9 (Auth Hooks - Custom Claims) v1.25
- [supabase-database-functions](../supabase-database-functions/SKILL.md) — pattern SECURITY DEFINER + supabase_auth_admin grants
- [supabase-auth-ssr](../supabase-auth-ssr/SKILL.md) — Next.js v16 + onAuthStateChange + jwt-decode integration
- [supabase-rls-hardener](../../agents/supabase-rls-hardener.md) (v1.23) — Detector 9 valida auth hook instalado (v1.25)
- [rbac-permissions-matrix-supabase](../rbac-permissions-matrix-supabase/SKILL.md) (v1.21) — alternativa via helper function STABLE (vs custom claim v1.25)
- [glossário compartilhado](../_shared-supabase/glossary.md) — termos custom claims, Custom Access Token Auth Hook, JWT user_role claim, authorize() function, supabase_auth_admin role, app_role enum, app_permission enum, jwt-decode client pattern
