Blocking hooks (before create, before sign-in)
Run your own check inside sign-up and sign-in: refuse a user with your own message or add custom claims to their tokens, from a webhook or one of your functions.
A blocking hook is your code in the middle of an auth flow, like Firebase's blocking functions. Sovaria calls it and waits for the answer before it creates the user or signs them in. Use it to allow only your company's domain, block users your fraud check flags, require an invite, or stamp a tenant id or plan into the user's tokens.
| Hook | Runs | Not called for |
|---|---|---|
before_create | Before a new app user is stored: email/password sign-up, and the first sign-in with a social or SSO provider. | Anonymous users, users your team creates (console, admin API), SCIM provisioning, imports. |
before_sign_in | After the password, code, link, passkey or provider was checked — before any session, token or MFA challenge. | Anonymous sign-ins. |
#What your hook answers
| Answer | Effect |
|---|---|
{"allow": false, "message": "Use your work email"} (or {"deny": true}) | Refused: the app gets 403 AUTH_HOOK_DENIED with your message (up to 200 characters) — the SDKs show it to the user as is. |
{"claims": {"tenant": "acme", "tier": "gold"}} | Allowed, and the claims are stored on the user and added under ctx in every token from now on (refreshes too). Send null for a claim to remove it. |
Anything else ({}, an empty body, {"allow": true}) | Allowed. |
- Claims Sovaria owns are dropped from your answer:
roles,permissions,org_id,project_id,env_id,aal,sub,emailand the other identity markers. Claims stay under 1,000 bytes. - Use the claims in row policies like any other:
auth.jwt() -> 'ctx' ->> 'tenant'.
#What your hook receives
{
"id": "4b0c…",
"type": "auth.before_sign_in",
"project_id": "c0ffee00-…",
"timestamp": "2026-10-23T09:12:44.120Z",
"user": { "id": "8d1e…", "email": "ada@acme.com", "email_verified": true, "provider": "pwd" },
"context": { "method": "pwd", "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …", "aal": "aal1" }
}before_create has no user.id yet and adds display_name. id is unique per call and timestamp lets you refuse a replayed request (accept only the last few minutes).
#A webhook
Sovaria POSTs the event to your HTTPS URL with X-Sovaria-Signature: sha256=<hex>, the HMAC-SHA-256 of the raw body with the hook's secret (shown once when you save the hook; rotate it any time). Check it before trusting the body.
import crypto from 'node:crypto'
app.post('/hooks/before-sign-in', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.HOOK_SECRET).update(req.body).digest('hex')
const got = req.get('X-Sovaria-Signature') ?? ''
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return res.sendStatus(401)
const event = JSON.parse(req.body)
if (!event.user.email.endsWith('@acme.com')) return res.json({ allow: false, message: 'Use your Acme email.' })
res.json({ claims: { tenant: 'acme' } })
})#One of your functions
Point the hook at a function of the project instead of a URL. It is invoked with { "event": {...}, "signature": "sha256=…" } — the HMAC of the event as canonical JSON (sorted keys, no spaces) — so it can tell Sovaria's call from someone invoking it directly, and returns the same answer.
import { createHmac } from 'node:crypto'
const canonical = (v: unknown): string => JSON.stringify(v, (_k, x) =>
x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x)
export default async function handler({ event, signature }: { event: any; signature: string }) {
const expected = 'sha256=' + createHmac('sha256', process.env.HOOK_SECRET!).update(canonical(event)).digest('hex')
if (signature !== expected) return { allow: false, message: 'Unsigned call' }
return event.user.email.endsWith('@acme.com') ? { claims: { tenant: 'acme' } } : { allow: false, message: 'Use your Acme email.' }
}#Timeouts and failures
- Each hook has a timeout (default 2 s, at most 5 s — staff can change the cap in the Control Center).
- Fail closed (the default): if the hook errors, times out or answers something unreadable, the sign-up or sign-in is refused with
503 AUTH_HOOK_UNAVAILABLE. Right for allow-lists and fraud checks — but if a fail-closedbefore_sign_inhook goes down, nobody can sign in until it is back. - Fail open: the flow continues without your claims. Right for enrichment.
- Refusals and failures are written to the project's audit log. Use Test in the console (or
sov auth-hooks test) after every change. - If a broken hook locks your users out and you can't reach the console, Sovaria support can switch the project's hooks off; sign-ups and sign-ins then run without them.
#Set one up
Authentication › Auth hooks › Blocking hooks › Set upOnly the project's owners and admins can see and change hooks (they receive your users' email addresses).
#Plans
| Free | Pro | Ultra | Team | Enterprise | |
|---|---|---|---|---|---|
| Blocking before-create / before-sign-in auth hooks | — | ✓ | ✓ | ✓ | ✓ |
On a plan without blocking hooks, saved hooks stay but don't run. Event hooks (notifications after the fact) are on every plan: see Auth event hooks.
#Coming from Firebase or Supabase?
| Platform | What this is called there |
|---|---|
| Firebase | Firebase Auth blocking functions (beforeCreate, beforeSignIn) |
| Supabase | Supabase Auth Hooks (custom access token, MFA) |
| Sovaria | Blocking hooks (before create, before sign-in) |
Moving an existing app? Migrate from Supabase or Firebase imports users (with their passwords), data, files and rules.