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, orpermissions - 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- Issueraud- Audienceexp- Expiration timeiat- Issued atsub- Subject (user ID)role- Postgres role (authenticated/anon)aal- Authentication Assurance Levelsession_id- Current sessionemail- 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:
- Index your lookup tables: Add indexes on
user_idcolumns - Minimize joins: Denormalize critical data if necessary
- Cache in app_metadata: For rarely-changing data, store in
raw_app_meta_dataand 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 DEFINERto run with elevated privileges - Set
search_pathexplicitly to prevent path manipulation attacks - Never trust user-modifiable claims (
user_metadata) for authorization decisions - The
raw_app_meta_datacolumn 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
- Row Level Security for Self-Hosted Supabase - Use your custom claims in RLS policies
- Setting Up OAuth Providers - Combine OAuth with custom claims
- Enterprise SSO and SAML - Auth hooks for enterprise integrations
- Multi-Tenant Architecture - Organization-based claims
