Skip to content

Auth Hooks — Knowledge Hub Supabase Projects

Auth Hooks — Knowledge Hub Supabase Projects

Section titled “Auth Hooks — Knowledge Hub Supabase Projects”

Authoritative reference for Postgres-function and Edge-function auth hooks attached to our Supabase Auth flows. Hooks created outside development sessions (via the Supabase dashboard) MUST be documented here.

  • All production auth hooks MUST have a corresponding migration file under supabase/migrations/. If a hook was created via dashboard, write a migration manually and push via supabase db push. The migration captures the function.
  • Hook wiring (which event triggers which function) is not currently being applied from supabase/config.toml on any project — treat re-wiring as a manual step on staging AND prod after any project reset. The [remotes.staging.auth.hook.before_user_created] block (supabase/config.toml:106-108) declares the staging wiring, but nothing is applying it today: as of S494 (25/07/2026) the Supabase GitHub integration is NOT connected — a preview-branch deployment log reports “Additional skipped steps: configuration” annotated “only available for Branching via GitHub”, and supabase branches list --project-ref zjqbrdctesqvouboziae returns an empty git_branch on the production branch. The other way to apply it, supabase config push, is not yet safe to run here (id-365 — [auth] site_url is local-dev-shaped).
  • RESOLVED S494 — the hook IS wired and working on Platform staging AND prod. Owner confirmed from the dashboard, which is the only way to read this back (there is no config pull, and config push has no --dry-run). The GitHub integration has also been connected previously and is believed to have worked, so the config-driven claim this bullet used to make may well have been true when written. Either way the wiring persists on both long-lived projects and is not at risk today; what is at risk is a project reset, after which it must be re-applied by hand on both — nothing is applying config.toml automatically. Note the failure direction when reasoning about this: because the hook is fail-closed, an unwired hook does not present as a security hole — it presents as sign-ups silently working when they should be gated.
  • Live probe, S494 — the hook is NOT wired on a preview branch. Measured on asqtderryucdhbvmajan (a branch of Platform prod, signup_policy empty by construction): POST /auth/v1/signup with an out-of-domain address returned HTTP 200 and created a user, where a wired hook would have rejected with the fail-closed 403 “Sign-up is currently unavailable: the allowed email domain is not configured.” The on_auth_user_created trigger fired correctly alongside it (a viewer row in user_roles plus the user_profiles mirror), so the trigger chain is healthy — it is specifically the hook that is absent. Combined with the owner’s dashboard confirmation that prod IS wired, this settles a question DR-085 left open: auth-hook wiring is NOT among the surfaces a branch inherits from its parent. Supabase describes a branch as “a direct copy of production, including … configurations”, and exposed-schema config demonstrably was inherited on this same branch (measured api, not the platform default) — so inheritance is selective, and [auth.hook.*] falls outside it. That is a concrete, measured instance of the DR-085 “cluster F” Auth-flag gap, and it is the first direct evidence for it rather than an inference. Security consequence, and it is the reason this matters beyond bookkeeping: every preview branch has NO sign-up gate. Anyone holding a branch’s publishable key can create an account on it. Ephemeral e2e branches are short-lived and synthetic, so the exposure is bounded today — but any branch-provisioning path that is expected to mirror the parent’s auth posture must wire this explicitly, exactly as DR-085 prescribes. asqtderryucdhbvmajan was deleted after this probe.
  • For prod, wiring has always been dashboard-configured and is NOT capturable in SQL. The [remotes.staging.*] config block is retained, not deleted — it records the intent that id-365 {365.4} makes live again.
  • Do NOT “fix” this by promoting the block to a base [auth.hook.before_user_created]. Configure carries every [auth.hook.*] leg but each is pointer-guarded, so a preview branch falls through to the commented-out base block and gets nothing. Promoting it would silently arm a FAIL-CLOSED hook on every preview branch, where signup_policy is empty by construction — verified on a live branch, which returns 403 “Sign-up is currently unavailable”. Seeding the policy row through the tenant-provisioning path is the real fix (id-367 {367.5}).
  • Hook changes require admin notification — update this doc and the relevant continuation prompt.
  • Hooks are enabled per Supabase project. The live model is four databases — Platform prod/staging + Client prod/staging; see reference/platform-context.md for refs. rovrymhhffssilaftdwd, listed here previously as “main prod”, is now the Client prod project; ztiztwqlyqcsuyhtjoya is not part of the current topology. Every project that has run the canonical migration chain HAS the function; whether it is WIRED is per-project and independent (see hook wiring above).
  • Service-role calls to supabase.auth.admin.createUser() bypass all auth hooks. This is Supabase’s documented behaviour.

2.1 hook_restrict_signup_to_allowed_domain

Section titled “2.1 hook_restrict_signup_to_allowed_domain”
  • Projects: all four (Platform prod/staging, client prod/staging) — the function ships in the canonical migration set, so every project that has run the chain has it.

  • Type: Postgres function hook (PL/pgSQL), SET search_path = public, extensions

  • Event: before-user-created

  • Purpose: Restrict sign-ups to ONE configured email domain per instance. Blocks any other address from signInWithOtp(), password sign-up, or any other user-creation flow.

  • Allowlist source — table-driven, NOT hardcoded: public.signup_policy, a singleton table (id boolean DEFAULT true, CHECK (id = true); allowed_domain text, nullable). The value is set out of band per environment (operator CLI / SQL editor) and is NEVER committed — that is deliberate, so no client domain enters tracked source. Renamed from an earlier client-specific function name for the same reason.

  • FAIL-CLOSED when unconfigured: if allowed_domain is NULL or empty, the hook REJECTS with HTTP 403 "Sign-up is currently unavailable: the allowed email domain is not configured." A wired hook with no policy row denies everyone rather than silently admitting anyone. Do not “fix” a locked-out instance by unwiring the hook — seed the row.

  • Error on rejection (configured): "Please sign up with your @<allowed_domain> email address." (HTTP 403) — the domain is interpolated from the table at call time, so the message follows the config with no code change.

  • Function body: supabase/migrations/20260617130000_squash_baseline.sql:3872-3905. Not reproduced here — a copy in this doc is what drifted last time.

  • Supporting objects: signup_policy has RLS enabled (20260617130000_squash_baseline.sql:11018) with a single SELECT policy auth_admin_reads_signup_policy … TO supabase_auth_admin (:10363); grants are service_role ALL + supabase_auth_admin SELECT (:13475-13476). An api.signup_policy security_invoker view (:7369-7375, service_role CRUD at :13480) is the surface the operator CLI writes the row through.

  • Permissions: EXECUTE revoked from PUBLIC; granted to supabase_auth_admin only (20260617130000_squash_baseline.sql:12763-12764) — Supabase docs best practice for auth hooks.

  • Dashboard URL pattern: https://supabase.com/dashboard/project/<project-ref>/auth/hooks

  • History: created by Liam out-of-band via the Supabase Dashboard, 2026-04-24 — client-specific, with a hardcoded domain. First surfaced in the S195 Inv-3 audit; generalised to the table-driven form and absorbed into the squash baseline by 2026-06-17.

  • Migration status: the standalone 20260424202806 capture migration no longer exists — its content is in the squash baseline. (A second, later migration, 20260603121652, carried the generic config-driven form and was likewise absorbed and deleted; the two are distinct, and both statements are correct.) A project reset replays the function definition; wiring is not replayed — see §1.

  • Bypass / exceptions:

    • supabase.auth.admin.createUser() (service-role) bypasses all hooks.
    • Existing users signing in do not trigger this hook — it only fires on new user creation.
    • The pipeline service account (a0000000-0000-4000-8000-000000000001, pipeline@system.knowledge-hub.internal) pre-dates the hook and is unaffected.
  • Operational notes:

    • To change the allowed domain, UPDATE public.signup_policy SET allowed_domain = …. No migration, no dashboard change, no redeploy. Setting it to NULL re-closes the gate.
    • Multi-domain is NOT supported — the table holds one domain and the hook compares for equality. A second domain needs a schema + function change, not a data change.
    • After editing the function, no dashboard reconfiguration is needed — the hook points to the function by name, so updating the function body transparently changes the behaviour.
Section titled “3. Related: user_roles auto-seeding trigger”

Not an auth hook per se, but tightly coupled. The on_auth_user_created AFTER INSERT trigger on auth.users calls public.handle_new_user() — a consolidated function that seeds BOTH a viewer row in public.user_roles and the mirror row in public.user_profiles. It replaced the standalone handle_new_user_role under WP-G3.4 (spec: docs/audits/kh-production-readiness-phase-1/specs/wp-g3.4-user-profiles-spec-v1.md §4.3). Captured in supabase/migrations/20260617130000_squash_baseline.sql:3846 (function

  • COMMENT) and :13812 (trigger); the previously cited 20260416102457_pre_squash_reconciliation.sql no longer exists. RPC exposure is deliberately REVOKEd; the trigger fires via owner privileges.
  • Changing an existing hook: Prefer migration over dashboard edit. If dashboard-first, capture via migration + update this doc within 24h.
  • New hook: Migration preferred. If dashboard-first, write the migration manually (dashboard hooks are not captured by supabase db pull).
  • Disabling a hook: Via Dashboard → Authentication → Hooks. Document the change here with date and reason.
  • Dashboard hooks do NOT sync to local supabase start. The local Supabase instance has no knowledge of dashboard-configured hooks. To test locally, manually create the function in the local database and wire it via the local auth config.
  • E2E testing: Currently no E2E coverage for hook rejection. Two cases worth covering: signup with an out-of-domain address against a configured signup_policy row, and signup with NO row at all (the fail-closed 403). runbooks/client-app-deploy.md §4 lists the latter as a manual stand-up check — an E2E would retire that manual step.
  • Capture the hook in a migration — now carried by the squash baseline (20260617130000_squash_baseline.sql:3872); the original standalone 20260424202806 capture migration was absorbed and deleted.
  • Revoke EXECUTE from authenticated, anon, public, service_role — only supabase_auth_admin retains EXECUTE (20260617130000_squash_baseline.sql:12763-12764).
  • Add “Auth Hooks” cross-reference to docs/reference/SCHEMA-QUICK-REFERENCE.md — added §37 (verified S205C audit; SCHEMA-QUICK-REFERENCE.md line 1855).
  • Wire staging-branch auth hook via supabase/config.toml [remotes.staging.auth.hook.before_user_created] block (S22 W2 Hold (d) ratification → committed in cf8a677c). See §1 — the block is not being applied today, and whether a past Configure run ever applied it is unverified.
  • Migrate the hardcoded domain check to a table-driven allowlist — DONE: public.signup_policy + hook_restrict_signup_to_allowed_domain, shipped in the squash baseline. Multi-domain remains unsupported by design.