Files
supabase/apps/docs/content/guides/self-hosting/self-hosted-auth-hooks.mdx

351 lines
12 KiB
Plaintext

---
title: 'Configure Auth Hooks'
description: 'Set up auth hooks for self-hosted Supabase with Docker.'
subtitle: '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](/docs/guides/self-hosting/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](/docs/guides/auth/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)
| Hook | Hook Name | Description |
| -------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------- |
| [Custom Access Token](/docs/guides/auth/auth-hooks/custom-access-token-hook) | `CUSTOM_ACCESS_TOKEN` | Add claims to JWTs before they are issued |
| [Send SMS](/docs/guides/auth/auth-hooks/send-sms-hook) | `SEND_SMS` | Replace built-in SMS sending with a custom provider |
| [Send Email](/docs/guides/auth/auth-hooks/send-email-hook) | `SEND_EMAIL` | Replace built-in email sending with a custom provider |
| [Before User Created](/docs/guides/auth/auth-hooks/before-user-created-hook) | `BEFORE_USER_CREATED` | Run checks or block signups before creating a user |
| [MFA Verification](/docs/guides/auth/auth-hooks/mfa-verification-hook) | `MFA_VERIFICATION_ATTEMPT` | Validate MFA attempts (rate limit, brute-force protection) |
| [Password Verification](/docs/guides/auth/auth-hooks/password-verification-hook) | `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` |
<Admonition type="note">
Postgres function hooks run inside your database, so there is no network overhead and no need to manage secrets.
</Admonition>
<Admonition type="note">
`http://` URIs are only allowed for `localhost`, `127.0.0.1`, `::1`, and `host.docker.internal` hostnames.
</Admonition>
## 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.
```sql name=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:
```sql name=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:
```yaml name=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
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`:
```sql
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:
```sh
docker compose logs auth --tail 20
```
## Step-by-step: HTTP endpoint hook
This example enables the **Send SMS** hook using an Edge Function.
<Admonition type="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.
</Admonition>
### Step 1: Create the Edge Function
Create `volumes/functions/send_sms/index.ts`:
```ts name=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:
```sh
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:
```bash name=.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.
```yaml name=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
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:
```sh
docker compose logs auth --tail 20
docker compose logs functions --tail 20
```
## Webhook secrets
HTTP hooks use the [Standard Webhooks](https://www.standardwebhooks.com/) specification for payload signing.
### Generating a secret
```sh
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:
```bash name=.env
SEND_EMAIL_HOOK_SECRET=v1,whsec_new-secret|v1,whsec_old-secret
```
```yml name=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.
<Admonition type="note">
Postgres function hooks (`pg-functions://` URIs) do not require secrets - they run directly inside the database.
</Admonition>
## 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:
```sh
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:
```sql
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`:
```sql
grant usage on schema public to supabase_auth_admin;
```
### SMS OTP expiry is too short
Refer to [OTP Settings Docs](/docs/guides/self-hosting/self-hosted-phone-mfa#otp-settings)