Skip to main content

Configure Auth Hooks

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

Note

Postgres function hooks run inside your database, so there is no network overhead and no need to manage secrets.

Note

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

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.

Note

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_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.

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:

SEND_EMAIL_HOOK_SECRET=v1,whsec_new-secret|v1,whsec_old-secret
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.

Note

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}_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