Auth

Blocking Hooks

A blocking hook is an endpoint of yours that auth calls, and waits for, before it does something: creating an account, signing someone in, issuing a token. The endpoint answers allow or deny, and auth goes on or refuses. An allow before a token can also put claims of yours in it. The call and the answer are both signed with the hook's own secret, so auth knows the answer is yours and you know the call is auth's.

What happens after an operation is not a hook: it is an event, delivered to the project's webhooks (user.deleted, session.ended, user.converted …), and it cannot change what happened (Webhooks).

Quick example

Register the hook. Its signing secret is printed this once:

palbase auth:hooks:create --event before.user.create --url https://hooks.example.com/auth
Registered hook_01J…: before.user.create → https://hooks.example.com/auth.
signing secret (shown this once): whsec_…

Keep it where the endpoint reads it, here AUTH_HOOK_SECRET. Then the endpoint: any server that answers HTTPS at a public address. This one is Node with nothing installed:

// hooks/server.ts
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

// whsec_ and the key in base64, as auth:hooks:create printed it.
const key = Buffer.from(process.env.AUTH_HOOK_SECRET!.replace(/^whsec_/, ""), "base64");

function sign(id: string, timestamp: string, body: string): string {
  return createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
}

interface HookCall {
  event: string;
  user?: { id?: string; email?: string; email_verified?: boolean; is_anonymous?: boolean };
  context?: { ip?: string; user_agent?: string; risk_score?: number };
}

interface HookAnswer {
  verdict: "allow" | "deny";
  reason?: string;
  custom_claims?: Record<string, unknown>;
}

function decide(call: HookCall): HookAnswer {
  if (call.event === "before.user.create" && call.user?.email && !call.user.email.endsWith("@example.com")) {
    return { verdict: "deny", reason: "Sign-ups are open to example.com addresses only." };
  }
  if (call.event === "before.token.issue") {
    // Your own data about the user goes into the token they are about to get.
    return { verdict: "allow", custom_claims: { plan: "pro" } };
  }
  return { verdict: "allow" };
}

createServer((req, res) => {
  const chunks: Buffer[] = [];
  req.on("data", (chunk: Buffer) => chunks.push(chunk));
  req.on("end", () => {
    // The signature is over the bytes as they arrived: read them before parsing anything.
    const body = Buffer.concat(chunks).toString("utf8");
    const id = String(req.headers["webhook-id"] ?? "");
    const timestamp = String(req.headers["webhook-timestamp"] ?? "");
    const expected = Buffer.from(`v1,${sign(id, timestamp, body)}`);
    const given = String(req.headers["webhook-signature"] ?? "").split(" ").map((s) => Buffer.from(s));
    const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
    if (!fresh || !given.some((s) => s.length === expected.length && timingSafeEqual(s, expected))) {
      res.writeHead(401).end();
      return;
    }

    // The answer is signed the same way, with an id and a time of its own.
    const answer = JSON.stringify(decide(JSON.parse(body) as HookCall));
    const answerId = `msg_${randomUUID()}`;
    const answerTimestamp = String(Math.floor(Date.now() / 1000));
    res.writeHead(200, {
      "content-type": "application/json",
      "webhook-id": answerId,
      "webhook-timestamp": answerTimestamp,
      "webhook-signature": `v1,${sign(answerId, answerTimestamp, answer)}`,
    });
    res.end(answer);
  });
}).listen(Number(process.env.PORT ?? 8080));

A sign-up with any other address is now refused with 403 hook_denied, and its message is your reason.

The events

EventAuth calls it beforeIt is sent
before.user.createAn account is made: an email sign-up, a passkey sign-up, a social sign-up, a guest, a guest becoming an accountuser.email, user.email_verified, user.is_anonymous (no id yet)
before.loginA password, magic-link, passkey or social sign-inuser.id, context.ip, context.user_agent, context.risk_score
before.password.resetA password reset email is sentuser.id
before.mfa.verifyAn authenticator-app or text code is checked as a second factoruser.id
before.social.linkA provider account is linked to a useruser.id
before.token.issueA user's access token: at every sign-in, whatever its way, and at every refreshuser.id
before.token.refreshA session is refresheduser.id, context.ip, context.user_agent
before.transaction.approveA user approves a transactionuser.id
before.device_grant.initiateA device asks for a code (OpenID Provider)—
before.device_grant.authorizeA user approves a device's codeuser.id

The body is { event, user?, context?, client_metadata? }; a social sign-in or sign-up also sends client_metadata.transaction_id. A phone-code sign-in, or a phone sign-up, calls before.token.issue alone.

The call and the answer

Auth POSTs the JSON body to the hook's URL with three headers, as Standard Webhooks has them:

HeaderWhat it is
webhook-idThe call's id, msg_….
webhook-timestampWhen it was sent, in Unix seconds.
webhook-signaturev1, and the base64 HMAC-SHA256, under the secret's key, of <webhook-id>.<webhook-timestamp>.<body>.

The key is the secret's part after whsec_, decoded from base64. An endpoint checks the signature against the body's bytes before it reads them, and refuses a timestamp more than a few minutes away.

The answer is 2xx with a JSON body, signed the same way: its own webhook-id, a webhook-timestamp within five minutes of auth's clock, and a webhook-signature over that id, that time and the answer's body, with the same key.

FieldWhat it says
verdictallow or deny.
reasonWhy, for a deny. A sign-up, a guest and a guest's conversion pass it to the person refused, as the message of their 403 hook_denied; other operations answer hook_denied in their own words.
custom_claimsOn an allow of before.token.issue: claims put in the access token under its custom_claims claim. A palbase/backend route reads them as the user's metadata (The UserT shape).

A token's claims are asked for again at each refresh, so they are never older than the token. A before.password.reset that denies sends no email, and the request answers as it always does, so no one learns from it whether the address has an account.

Several hooks on one event are called one after another, oldest first. The first deny refuses; the custom_claims of the allows are merged, a later hook's key winning.

Timeouts and failures

--timeout is how long auth waits for an answer, 100 to 5000 ms (5000 by default). A hook fails when it does not answer in that time, or answers with a status other than 2xx, without a valid signature, or with a body that is not JSON. What auth does then is the hook's --failure-mode:

failure_modeWhen the hook fails
deny (the default)The operation fails, as an error rather than as hook_denied: a sign-up or a password sign-in answers 500 internal_error.
allowThe operation goes on as if the hook had allowed it, with no claims added.

The two device-grant events go on when their hook fails, whatever its failure mode.

Choose deny for a hook that is a rule (who may sign up, who may sign in), and allow for one that only adds something. Every sign-in waits for its hooks, so put the endpoint where it answers at once: a machine of a size that sleeps makes the first sign-in after it fell asleep wait for it to wake.

The URL is https, and it must not resolve to a private address.

Managing hooks

palbase auth:hooks:list
palbase auth:hooks:update hook_01J… --timeout 2000 --failure-mode allow
palbase auth:hooks:update hook_01J… --disable
palbase auth:hooks:delete hook_01J…

auth:hooks:update changes the event, the URL, the timeout, the failure mode or the switch; the secret stays. A hook registered with --disable, or turned off, is not called. Deleting one stops the calls at once. The admin calls are GET/POST /admin/hooks and PUT/DELETE /admin/hooks/{id}; PUT replaces the hook whole, and an optional field it leaves out takes its default, not its current value.

GET /admin/hooks/{id}/executions lists a hook's calls, newest first, for thirty days: what it was sent (without the email address), what it answered, how long it took, and the verdict auth took (allow, deny, timeout or error). Deleting the hook deletes them.