Auth Hooks for Self-Hosted Supabase: Custom Claims and RBAC

Configure auth hooks on self-hosted Supabase to add custom JWT claims, implement role-based access control, and build secure authorization workflows.

Cover Image for Auth Hooks for Self-Hosted Supabase: Custom Claims and RBAC

Auth Hooks are one of the most powerful—and underutilized—features in Supabase. They let you intercept authentication events and modify tokens before they're issued. For self-hosted Supabase deployments, auth hooks unlock capabilities that would otherwise require a separate authorization service: custom JWT claims, role-based access control (RBAC), tenant isolation, and dynamic permission systems.

The challenge is that self-hosted configuration differs significantly from Supabase Cloud. There's no dashboard toggle—you're working with environment variables and Docker configuration. This guide walks through setting up auth hooks on self-hosted instances, implementing practical RBAC patterns, and avoiding the common pitfalls that trip up developers.

What Auth Hooks Actually Do

Auth hooks are PostgreSQL functions that Supabase Auth calls at specific points during authentication flows. The most powerful is the Custom Access Token Hook, which runs before every JWT is issued. You can:

  • Add custom claims like user_role, organization_id, or permissions
  • Modify existing claims based on database lookups
  • Implement dynamic RBAC without querying permissions tables on every request
  • Add tenant identifiers for multi-tenant applications

The hook receives the current JWT claims, your function modifies them, and Supabase Auth issues the token with your changes. All subsequent requests include your custom data, directly accessible via auth.jwt() in RLS policies.

Self-Hosted Configuration

Unlike Supabase Cloud, where you enable hooks through the dashboard, self-hosted deployments require environment variable configuration.

Docker Compose Setup

Add these environment variables to your GoTrue/Auth service in docker-compose.yml:

services:
  auth:
    image: supabase/gotrue:v2.170.0
    environment:
      # ... existing env vars
      GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true"
      GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook"

The URI format is pg-functions://postgres/{schema}/{function_name}. Replace custom_access_token_hook with your function name.

Local Development with Supabase CLI

For local development, add this to your supabase/config.toml:

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

Important: After adding the config, you need to restart Supabase entirely. Running supabase db reset alone won't activate the hook—you must run supabase stop followed by supabase start.

Building Your First Auth Hook

Start with a minimal hook that adds a simple claim:

CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  claims jsonb;
  user_role text;
BEGIN
  -- Extract existing claims
  claims := event->'claims';
  
  -- Look up the user's role from your roles table
  SELECT role INTO user_role
  FROM user_roles
  WHERE user_id = (claims->>'sub')::uuid;
  
  -- Add the custom claim
  claims := jsonb_set(claims, '{user_role}', to_jsonb(COALESCE(user_role, 'user')));
  
  -- Return the modified event
  RETURN jsonb_set(event, '{claims}', claims);
END;
$$;

-- Grant necessary permissions
GRANT USAGE ON SCHEMA public TO supabase_auth_admin;
GRANT EXECUTE ON FUNCTION custom_access_token_hook TO supabase_auth_admin;

This hook queries a user_roles table and adds the role to every JWT. The token now includes your user_role claim automatically.

Required Claims and Validation

Supabase Auth validates hook output before issuing tokens. Your function must preserve these required claims:

  • iss - Issuer
  • aud - Audience
  • exp - Expiration time
  • iat - Issued at
  • sub - Subject (user ID)
  • role - Postgres role (authenticated/anon)
  • aal - Authentication Assurance Level
  • session_id - Current session
  • email - User email (if present)
  • phone - User phone (if present)
  • is_anonymous - Anonymous user flag

If your hook removes or corrupts any required claim, authentication fails with a cryptic error. Always modify claims with jsonb_set() rather than reconstructing the entire claims object.

Implementing RBAC with Custom Claims

Here's a complete RBAC implementation with roles and permissions:

-- Create roles and permissions tables
CREATE TABLE roles (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text UNIQUE NOT NULL,
  description text
);

CREATE TABLE permissions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text UNIQUE NOT NULL
);

CREATE TABLE role_permissions (
  role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
  permission_id uuid REFERENCES permissions(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, permission_id)
);

CREATE TABLE user_roles (
  user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
  role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, role_id)
);

-- Insert default roles
INSERT INTO roles (name, description) VALUES
  ('admin', 'Full access to all resources'),
  ('moderator', 'Can manage content but not users'),
  ('user', 'Standard user access');

-- Insert permissions
INSERT INTO permissions (name) VALUES
  ('users:read'), ('users:write'), ('users:delete'),
  ('content:read'), ('content:write'), ('content:delete'),
  ('settings:manage');

Update your auth hook to include both role and permissions:

CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  claims jsonb;
  user_roles text[];
  user_permissions text[];
BEGIN
  claims := event->'claims';
  
  -- Get all roles for the user
  SELECT array_agg(r.name) INTO user_roles
  FROM user_roles ur
  JOIN roles r ON ur.role_id = r.id
  WHERE ur.user_id = (claims->>'sub')::uuid;
  
  -- Get all permissions from those roles
  SELECT array_agg(DISTINCT p.name) INTO user_permissions
  FROM user_roles ur
  JOIN role_permissions rp ON ur.role_id = rp.role_id
  JOIN permissions p ON rp.permission_id = p.id
  WHERE ur.user_id = (claims->>'sub')::uuid;
  
  -- Add claims
  claims := jsonb_set(claims, '{roles}', to_jsonb(COALESCE(user_roles, ARRAY['user']::text[])));
  claims := jsonb_set(claims, '{permissions}', to_jsonb(COALESCE(user_permissions, ARRAY[]::text[])));
  
  RETURN jsonb_set(event, '{claims}', claims);
END;
$$;

Now your JWTs include roles and permissions arrays. Use them in RLS policies:

CREATE POLICY "Admins can manage all users"
ON user_profiles
FOR ALL
USING (
  'admin' = ANY(
    (auth.jwt()->'roles')::text[]
  )
);

CREATE POLICY "Users with delete permission"
ON content
FOR DELETE
USING (
  'content:delete' = ANY(
    (auth.jwt()->'permissions')::text[]
  )
);

Multi-Tenant Claims

For multi-tenant architectures, add organization context to tokens:

CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  claims jsonb;
  org_ids uuid[];
  active_org uuid;
BEGIN
  claims := event->'claims';
  
  -- Get all organizations the user belongs to
  SELECT array_agg(organization_id) INTO org_ids
  FROM organization_members
  WHERE user_id = (claims->>'sub')::uuid;
  
  -- Get user's active/default organization
  SELECT default_org_id INTO active_org
  FROM user_preferences
  WHERE user_id = (claims->>'sub')::uuid;
  
  claims := jsonb_set(claims, '{organizations}', to_jsonb(org_ids));
  claims := jsonb_set(claims, '{active_org}', to_jsonb(active_org));
  
  RETURN jsonb_set(event, '{claims}', claims);
END;
$$;

Common Pitfalls and Debugging

Hook Function Errors

If your hook throws an error, authentication fails entirely. Always handle edge cases:

-- Wrap in exception handler
BEGIN
  -- Your logic
EXCEPTION WHEN OTHERS THEN
  -- Log the error if you have logging set up
  RAISE WARNING 'Auth hook error: %', SQLERRM;
  -- Return unmodified event to avoid blocking auth
  RETURN event;
END;

Performance Considerations

Auth hooks run on every token refresh. Complex queries add latency to login and token refresh operations. Optimization strategies:

  1. Index your lookup tables: Add indexes on user_id columns
  2. Minimize joins: Denormalize critical data if necessary
  3. Cache in app_metadata: For rarely-changing data, store in raw_app_meta_data and only refresh periodically
-- Check if we need to refresh claims from database
IF (claims->>'claims_refreshed_at')::timestamptz < NOW() - INTERVAL '1 hour' THEN
  -- Do the expensive lookup
  claims := jsonb_set(claims, '{claims_refreshed_at}', to_jsonb(NOW()));
END IF;

Testing Locally

Testing custom claims with the Supabase Studio impersonation feature works differently in local development versus production. Community reports indicate that SELECT auth.jwt() returns custom claims correctly in local dev, but the Studio impersonation UI may not reflect them accurately.

Test your hooks directly:

-- Simulate a hook call
SELECT custom_access_token_hook(
  jsonb_build_object(
    'claims', jsonb_build_object(
      'sub', 'your-user-uuid',
      'role', 'authenticated',
      'aud', 'authenticated',
      'iss', 'http://localhost:54321/auth/v1'
      -- ... other required claims
    )
  )
);

Security Reminders

  • Use SECURITY DEFINER to run with elevated privileges
  • Set search_path explicitly to prevent path manipulation attacks
  • Never trust user-modifiable claims (user_metadata) for authorization decisions
  • The raw_app_meta_data column is safer—users cannot modify it directly

When Auth Hooks Make Sense

Auth hooks aren't always the right solution. Consider them when:

  • You need claims in every request: Permissions, roles, tenant IDs
  • You want to avoid N+1 queries: Instead of checking permissions on every request, bake them into the JWT
  • Your authorization logic is relatively stable: Hooks run at login/refresh, so rapidly changing permissions may not reflect immediately

For highly dynamic permissions that change between requests, query the database directly. For most applications, refreshing permissions on token refresh (every hour by default) is sufficient.

Managing Supascale Deployments

When managing auth hooks across multiple self-hosted Supabase projects, consistency matters. Each project needs identical hook functions and matching environment configuration. Supascale handles this by letting you configure auth settings through its management interface, avoiding manual Docker configuration for each instance.

You can define your auth hook function once and deploy it across all your projects via database migrations, ensuring RBAC implementations stay synchronized.

Further Reading