Configure Auth Hooks
Set up auth hooks for self-hosted Supabase with Docker.
This guide covers the server-side configuration required to enable auth hooks on a self-hosted Supabase instance running with Docker Compose. Auth hooks let you run custom logic at specific points in the authentication flow - for example, adding claims to JWTs, sending SMS through a custom provider, or restricting signups.
Before you begin#
You need:
- A working self-hosted Supabase installation. See Self-Hosting with Docker.
- For Postgres function hooks: access to the database service to create functions.
- For HTTP endpoint hooks: a reachable HTTPS endpoint or a local Edge Function.
How hooks work#
For hook implementation details (input/output schemas, SQL and HTTP examples), see Auth Hooks.
Supabase Auth can call a hook at specific lifecycle events during the auth flow. Each hook can be configured with the following environment variables:
GOTRUE_HOOK_{HOOK_NAME}_ENABLED: Enable the hook (true/false)GOTRUE_HOOK_{HOOK_NAME}_URI: The hook endpointGOTRUE_HOOK_{HOOK_NAME}_SECRETS: Webhook signing secrets (for HTTP hooks)
| Hook | Hook Name | Description |
|---|---|---|
| Custom Access Token | CUSTOM_ACCESS_TOKEN | Add claims to JWTs before they are issued |
| Send SMS | SEND_SMS | Replace built-in SMS sending with a custom provider |
| Send Email | SEND_EMAIL | Replace built-in email sending with a custom provider |
| Before User Created | BEFORE_USER_CREATED | Run checks or block signups before creating a user |
| MFA Verification | MFA_VERIFICATION_ATTEMPT | Validate MFA attempts (rate limit, brute-force protection) |
| Password Verification | PASSWORD_VERIFICATION_ATTEMPT | Track and limit failed password attempts |
URI schemes#
Hooks support two URI schemes:
| Scheme | Format |
|---|---|
pg-functions:// | pg-functions://postgres/<schema>/<function_name> |
http:// or https:// | https://example.com/hook |
Postgres function hooks run inside your database, so there is no network overhead and no need to manage secrets.
http:// URIs are only allowed for localhost, 127.0.0.1, ::1, and host.docker.internal hostnames.
Step-by-step: Postgres function hook#
This example enables the Custom Access Token hook using a Postgres function that adds a user_role claim to the JWT.
Step 1: Create the Postgres function#
You can execute the following SQL from the Supabase Dashboard SQL Editor, or by connecting to your database using a Postgres client such as psql.
This example reads roles from a user_roles table, so create that table first. If the table is missing, the hook errors and every sign-in fails.
create table if not exists public.user_roles ( user_id uuid not null references auth.users on delete cascade, role text not null, primary key (user_id));Then create the hook function:
create or replace function public.custom_access_token_hook(event jsonb)returns jsonblanguage plpgsqlsecurity definerset search_path = ''as $$declare claims jsonb; user_role text;begin claims := event->'claims'; -- Example: look up a custom role from a user_roles table select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid; if user_role is not null then claims := jsonb_set( claims, '{user_role}', to_jsonb(user_role) ); end if; -- Return the modified claims return jsonb_build_object('claims', claims);end;$$;-- Grant execute permission to supabase_auth_admingrant execute on function public.custom_access_token_hook to supabase_auth_admin;-- Grant schema access to supabase_auth_admin (usually already granted by default)grant usage on schema public to supabase_auth_admin;-- Revoke from public and other rolesrevoke execute on function public.custom_access_token_hook from authenticated, anon, public;Step 2: Update docker-compose.yml#
Update the auth service environment: block:
services: auth: environment: # ... existing variables ... GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: 'true' # 👈 enabling the hook is required GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: 'pg-functions://postgres/public/custom_access_token_hook'Step 3: Relaunch the auth service#
sh run.sh recreate authStep 4: Verify the custom claim#
Give a user a role so the hook has something to add. Replace the UUID with a real user ID from auth.users:
insert into public.user_roles (user_id, role)values ('00000000-0000-0000-0000-000000000000', 'admin');Sign in as that user and decode the JWT. If the hook ran successfully, the user_role claim is present. A user with no matching row in user_roles still signs in, but without the claim. If something doesn't work, check the auth logs:
docker compose logs auth --tail 20Step-by-step: HTTP endpoint hook#
This example enables the Send SMS hook using an Edge Function.
The Send SMS hook only fires when Auth sends an OTP. Make sure phone auth is enabled (GOTRUE_EXTERNAL_PHONE_ENABLED=true) and automatic phone confirmation is off (GOTRUE_SMS_AUTOCONFIRM=false). When GOTRUE_SMS_AUTOCONFIRM is on, signups are confirmed without an OTP, so the hook never runs.
Step 1: Create the Edge Function#
Create volumes/functions/send_sms/index.ts:
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'// Note: this example assumes a single secret. If you use multiple secrets (e.g. "v1,whsec_new|v1,whsec_old"), split on '|'// and try each secret in turn until wh.verify() succeeds.const hookSecret = Deno.env.get('SEND_SMS_HOOK_SECRET')?.replace('v1,whsec_', '')Deno.serve(async (req) => { if (req.method !== 'POST') { return new Response('not allowed', { status: 400 }) } if (!hookSecret) { console.error('SEND_SMS_HOOK_SECRET environment variable not provided') return new Response('{}', { status: 500 }) } // Verify the webhook signature const payload = await req.text() const headers = Object.fromEntries(req.headers) const wh = new Webhook(hookSecret) const { user, sms } = wh.verify(payload, headers) // Send SMS using your provider // ... your sms sending logic here ... return new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' }, })})Step 2: Generate a webhook secret#
Generate a secret using the following command:
echo "v1,whsec_$(openssl rand -base64 32)"Copy the output of this command, for example: v1,whsec_abc123....
Step 3: Update .env file#
Add the following environment variables to your .env file:
SEND_SMS_HOOK_URI=http://host.docker.internal:8000/functions/v1/send_smsSEND_SMS_HOOK_SECRET=YOUR_GENERATED_SECRET_HERE # Paste the secret generated in the last step hereStep 4: Update docker-compose.yml#
Add the following environment variables to the auth and functions services. For brevity, only the updated fields are shown.
services: auth: environment: # ... existing variables ... GOTRUE_HOOK_SEND_SMS_ENABLED: 'true' GOTRUE_HOOK_SEND_SMS_URI: ${SEND_SMS_HOOK_URI} GOTRUE_HOOK_SEND_SMS_SECRETS: ${SEND_SMS_HOOK_SECRET} extra_hosts: # 👈 required so the container can resolve host.docker.internal - 'host.docker.internal:host-gateway' functions: environment: # ... existing variables ... SEND_SMS_HOOK_SECRET: ${SEND_SMS_HOOK_SECRET}Step 5: Relaunch auth and functions services#
sh run.sh recreate auth functionsStep 6: Verify the hook fires#
Trigger an SMS authentication event and confirm that the hook executes successfully.
If something doesn't work, check the auth and functions logs:
docker compose logs auth --tail 20docker compose logs functions --tail 20Webhook secrets#
HTTP hooks use the Standard Webhooks specification for payload signing.
Generating a secret#
echo "v1,whsec_$(openssl rand -base64 32)"Secret format#
- Symmetric:
v1,whsec_[base64]{32-88 characters}
Key rotation#
Separate multiple secrets with | to rotate keys without downtime. For example:
SEND_EMAIL_HOOK_SECRET=v1,whsec_new-secret|v1,whsec_old-secretservices: auth: environment: # ... existing variables ... GOTRUE_HOOK_SEND_EMAIL_ENABLED: 'true' GOTRUE_HOOK_SEND_EMAIL_URI: 'https://example.com' GOTRUE_HOOK_SEND_EMAIL_SECRETS: ${SEND_EMAIL_HOOK_SECRET}The Auth service signs each request with all configured secrets, so receivers can verify against either. Once all clients accept the new secret, remove the old one.
Postgres function hooks (pg-functions:// URIs) do not require secrets - they run directly inside the database.
Troubleshooting#
Hook not firing#
- Check that
GOTRUE_HOOK_{HOOK_NAME}_ENABLEDis set to"true"(as a string) indocker-compose.yml - Verify the variable reaches the container:
sh run.sh printenv auth | grep GOTRUE_HOOK - Remember:
.envvariables do not reach the container unless passed through indocker-compose.yml
pg-functions:// URI errors#
The URI format must be exactly pg-functions://postgres/<schema>/<function_name>:
- Use
postgresas the host by convention. The host segment is not validated. - Schema and function name must be valid Postgres identifiers
- The function must exist and be granted to
supabase_auth_admin
HTTP hook returns errors#
Check auth logs for details:
docker compose logs auth --tail 20Common causes:
- The endpoint is not reachable from the auth container
http://is only allowed forlocalhost,127.0.0.1,::1, andhost.docker.internal
Webhook secret format mismatch#
Secrets must match the Standard Webhooks format:
- Symmetric:
v1,whsec_[base64](32-88 base64 characters after the prefix) - No spaces or newlines in the secret string
- Generate with:
echo "v1,whsec_$(openssl rand -base64 32)"
Permission denied on Postgres function#
The supabase_auth_admin role needs execute on the function. By default every role inherits execute from the public role, but the setup in Step 1 revokes it from public, so you must grant it back to supabase_auth_admin explicitly. Otherwise sign-in fails with 500: Error running hook URI, and the auth logs show a permission-denied error:
grant execute on function public.your_hook_function to supabase_auth_admin;The role also needs usage on the schema. On a default install it already has this through the built-in grant on the public schema, but grant it explicitly if your database has revoked usage from public:
grant usage on schema public to supabase_auth_admin;SMS OTP expiry is too short#
Refer to OTP Settings Docs