Appearance
Auth setup — sign-in providers & the Pending gate
Dispatch signs members in with Google, GitHub, or email+password (Apple is parked — see below). Signup is open: any account can register. New users get a public.users profile with role Pending and no member data access until a Managing Partner promotes them — membership is decided in the app (the Pending gate), not by the identity provider.
1. Create the Google OAuth client
Console: https://console.cloud.google.com/apis/credentials.
- Project — create or select a project (e.g. "Dispatch").
- OAuth consent screen (APIs & Services → OAuth consent screen):
- User type: External — any Google account may sign in; the app's Pending gate (not the consent screen) decides who becomes a member. Fill in app name, support email, developer contact.
- Default scopes (
email,profile,openid) are enough.
- Credentials → Create credentials → OAuth client ID:
- Application type: Web application.
- Authorized redirect URIs (the Supabase callback, not the app):
- local:
http://127.0.0.1:54321/auth/v1/callback - hosted:
https://<project-ref>.supabase.co/auth/v1/callback
- local:
- (Optional) Authorized JavaScript origins:
http://localhost:4200+ the production web origin. - Create → copy the Client ID and Client secret.
2. Give the credentials to Supabase
These are secrets — never commit them.
Local dev — export before
supabase start/supabase db reset(supabase/config.tomlreads them viaenv(...)):bashexport GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com export GOOGLE_SECRET=yyyyHosted / prod — Supabase Dashboard → Authentication → Providers → Google → enable and paste the Client ID + secret; set the site URL + redirect allow-list.
3. GitHub OAuth app
GitHub sign-in uses a GitHub OAuth App (Settings → Developer settings → OAuth Apps → New OAuth App; use the org's settings for a shared app).
Local — Authorization callback URL:
http://127.0.0.1:54321/auth/v1/callback, then export beforesupabase start(config.tomlreads them viaenv(...)):bashexport GITHUB_CLIENT_ID=zzzz export GITHUB_SECRET=wwwwHosted / prod — a second OAuth App whose callback is
https://<project-ref>.supabase.co/auth/v1/callback; enable the GitHub provider in the Supabase Dashboard and paste its Client ID + secret.
GoTrue links accounts by verified email: a GitHub sign-in with an email that already has a password account lands on the same user (one public.users row, two identities).
4. Email+password, and Apple
Email+password is enabled with email confirmations: signup sends a confirmation link (locally caught by the dev mailbox, supabase status → Inbucket/Mailpit URL; hosted needs an SMTP provider configured in the Dashboard) and unconfirmed accounts cannot sign in. Password recovery emails work the same way.
Apple is parked until an Apple Developer membership exists: the [auth.external.apple] block in config.toml is enabled = false and the web app hides the button behind environment.auth.appleEnabled.
5. Open signup — the Pending gate
There is no domain restriction: the consent screen is External, the web client sends no hd parameter, and the DB accepts any email domain.
- The DB trigger
public.handle_new_usercreates apublic.usersprofile for every new auth user (any provider, any domain) with rolePending(migration20260715000100_open_signup.sql; name from the OIDCname/full_nameclaim or user metadata). public.is_member()is ANDed onto every member-data table as a restrictive RLS policy, so a Pending user sees only their ownpublic.usersrow; the Go API additionally refuses Pending callers on its member routes (403). Seedocs/authz-matrix.md.- The web app parks Pending users on
/pending; a Managing Partner promotes them to Member Partner from the back office, and "Check again" (a token refresh) picks the promotion up.
6. Verify
- New account (any domain, any provider) → a
public.usersrow exists withmembership_role = 'Pending'; the app lands on/pendingand member data is unreadable. - Promote the row to
Member Partner→ after "Check again" (token refresh) the app enters the member shell and member routes return data.
Real OAuth needs the hosted callback, so locally this is exercised with email+password users (see the e2e suite) or by inserting into auth.users directly.
7. Membership role in the JWT (access-token hook)
libs/auth gates routes on a membership_role claim that Google/GoTrue does not emit on its own. Migration 20260615020000_access_token_hook.sql adds a custom access-token hook (public.custom_access_token_hook) that GoTrue runs on every access-token issue/refresh: it reads the member's role from public.users and writes it into the claims. It is enabled in config.toml:
toml
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"The Go API verifies tokens with auth.NewVerifier(secret, jwksURL), which accepts two signing schemes by algorithm:
- ES256 (hosted projects, the Supabase default) — verified against the project's public JWKS, derived from
SUPABASE_URL(…/auth/v1/.well-known/jwks.json). Keys are cached bykidand refetched on rotation. Nothing secret to configure. - HS256 (the local stack) — verified with the shared
SUPABASE_JWT_SECRET(theJWT_SECRETfromsupabase status).
config.Load() wires both: SUPABASE_JWT_SECRET for HS256 and the JWKS URL derived from SUPABASE_URL for ES256. Point SUPABASE_URL at the hosted project and ES256/JWKS verification works with no secret.
Verify the claim locally
bash
ANON_KEY=$(supabase status -o json | jq -r .ANON_KEY)
SERVICE_ROLE_KEY=$(supabase status -o json | jq -r .SERVICE_ROLE_KEY)
# 1. Create a user (any domain — its profile starts as Pending)
curl -s "http://127.0.0.1:54321/auth/v1/admin/users" \
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"test-password-123","email_confirm":true}'
# 2. (optional) promote the role in SQL:
# update public.users set membership_role = 'Member Partner' where email = 'test@example.com';
# 3. Sign in and inspect the token — it should carry "membership_role"
curl -s "http://127.0.0.1:54321/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"test-password-123"}' | jq -r .access_tokenDecode the token's middle segment (… | cut -d. -f2 | base64 -d) and confirm "membership_role" is present ("Pending" until promoted).