Skip to content
Self-Hosting

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 endpoint
  • GOTRUE_HOOK_{HOOK_NAME}_SECRETS: Webhook signing secrets (for HTTP hooks)
HookHook NameDescription
Custom Access TokenCUSTOM_ACCESS_TOKENAdd claims to JWTs before they are issued
Send SMSSEND_SMSReplace built-in SMS sending with a custom provider
Send EmailSEND_EMAILReplace built-in email sending with a custom provider
Before User CreatedBEFORE_USER_CREATEDRun checks or block signups before creating a user
MFA VerificationMFA_VERIFICATION_ATTEMPTValidate MFA attempts (rate limit, brute-force protection)
Password VerificationPASSWORD_VERIFICATION_ATTEMPTTrack and limit failed password attempts

URI schemes#

Hooks support two URI schemes:

SchemeFormat
pg-functions://pg-functions://postgres/<schema>/<function_name>
http:// or https://https://example.com/hook

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.

user_roles.sql
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:

custom_access_token_hook.sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
security definer
set 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_admin
grant 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 roles
revoke execute on function public.custom_access_token_hook
from authenticated, anon, public;

Step 2: Update docker-compose.yml#

Update the auth service environment: block:

docker-compose.yml
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 auth

Step 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 20

Step-by-step: HTTP endpoint hook#

This example enables the Send SMS hook using an Edge Function.

Step 1: Create the Edge Function#

Create volumes/functions/send_sms/index.ts:

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:

.env
SEND_SMS_HOOK_URI=http://host.docker.internal:8000/functions/v1/send_sms
SEND_SMS_HOOK_SECRET=YOUR_GENERATED_SECRET_HERE # Paste the secret generated in the last step here

Step 4: Update docker-compose.yml#

Add the following environment variables to the auth and functions services. For brevity, only the updated fields are shown.

docker-compose.yml
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 functions

Step 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 20
docker compose logs functions --tail 20

Webhook 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:

.env
SEND_EMAIL_HOOK_SECRET=v1,whsec_new-secret|v1,whsec_old-secret
docker-compose.yml
services:
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.

Troubleshooting#

Hook not firing#

  • Check that GOTRUE_HOOK_{HOOK_NAME}_ENABLED is set to "true" (as a string) in docker-compose.yml
  • Verify the variable reaches the container: sh run.sh printenv auth | grep GOTRUE_HOOK
  • Remember: .env variables do not reach the container unless passed through in docker-compose.yml

pg-functions:// URI errors#

The URI format must be exactly pg-functions://postgres/<schema>/<function_name>:

  • Use postgres as 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 20

Common causes:

  • The endpoint is not reachable from the auth container
  • http:// is only allowed for localhost, 127.0.0.1, ::1, and host.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