Docs

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.

Updated Oct 23, 2026

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.

event (waits)allow + claimsYour appsign up · sign inSovaria Authchecks thecredentialsYour hookwebhook orfunction · signedUser created / tokens issuedwith your claims
HookRunsNot called for
before_createBefore 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_inAfter the password, code, link, passkey or provider was checked — before any session, token or MFA challenge.Anonymous sign-ins.

#What your hook answers

AnswerEffect
{"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, email and 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-closed before_sign_in hook 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 up

Only the project's owners and admins can see and change hooks (they receive your users' email addresses).

#Plans

FreeProUltraTeamEnterprise
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?

PlatformWhat this is called there
FirebaseFirebase Auth blocking functions (beforeCreate, beforeSignIn)
SupabaseSupabase Auth Hooks (custom access token, MFA)
SovariaBlocking hooks (before create, before sign-in)

Moving an existing app? Migrate from Supabase or Firebase imports users (with their passwords), data, files and rules.