Flow hooks and claim mappers
Configure additive admission refusals and client-scoped OIDC claims without adding code to the authentication hot path. This vocabulary is append-only: existing predicate names will not be renamed or reinterpreted.
Ordering and security fences
pre_sign_up and pre_sign_in rules run in file order. The first matching rule wins; when no rule matches, the default is allow. A matching allow means only “no hook refuses.” It never bypasses a user ban, credential lockout, email policy, rate limit, request origin validation, or callback/redirect fence.
The fence order is fixed: rate-limit middleware runs before callback URL validation; a route's validate_origin check runs at handler entry; only then may the handler load its existing facts and evaluate a pre-hook, before that sign-in family's first mutating write. A request refused by any earlier fence never reaches a hook. The closed v1 predicate names are email, email_domain, auth_method, provider_id, and client_id; this vocabulary is append-only.
post_sign_in is annotate-only. Its non-empty label is limited to 64 characters at boot and is emitted through the existing event sink as session.annotated, whose data is exactly {"id": session id, "userId": user id, "label": configured label}. It is never stored as a session-row flag and carries no email, name, provider, token, or predicate fact. Hook evaluation is an in-memory check over facts the path already loaded and may not add a database query or network request.
Facts available by sign-in family
A fact slot may be absent on a particular attempt: for example, a direct password sign-in has no social provider, and client_id exists only when authentication is completing an OAuth authorization. An absent fact does not match a predicate that names it. SSO alone may use the already-loaded organization assertion facts before identity commit.
| Sign-in family | Email + domain | Auth method | Provider id | OAuth client id | Organization facts |
|---|---|---|---|---|---|
| Password / email | yes | password | absent | when resuming OAuth | no |
| Username | stored user email | username | absent | when resuming OAuth | no |
| Phone | stored user email | phone | absent | when resuming OAuth | no |
| Email or phone OTP | resolved user email | otp | absent | when resuming OAuth | no |
| Magic link | resolved user email | magic_link | absent | when resuming OAuth | no |
| SIWE | wallet account email | siwe | absent | when resuming OAuth | no |
| Anonymous | generated anonymous email | anonymous | absent | when resuming OAuth | no |
| Passkey | stored user email | passkey | absent | when resuming OAuth | no |
| Social OAuth | provider-asserted email | social | social provider id | when resuming OAuth | no |
| Enterprise SSO (SAML or OIDC) | assertion email | sso | SSO provider id | when resuming OAuth | organization id, membership and role from the assertion path |
| OIDC authorize | absent (the platform session does not carry email) | oidc_authorize | absent | registered OAuth client id | no |
X-Forwarded-For without a trusted-proxy declaration, so making an authorization decision from it would trust attacker-controlled input. An ip predicate stops boot and names that precondition.Refusal rendering
Operator-supplied hook prose is for logs and events only. It never appears in a JSON body, HTML page, or Location header.
On shared-credential paths, including email/password and username/password, a hook refusal renders as the path’s pinned INVALID_EMAIL_OR_PASSWORD response, byte for byte. It also runs the dummy password hash and advances persistent lockout exactly like a wrong password. Matching the bytes alone is insufficient: equal hashing work and equal lockout side effects prevent account and policy enumeration.
On /oauth2/authorize, the redirect URI is the security fence. Before that URI has been validated against the client, a refusal is a fixed JSON 400 with no Location. After the fence, the refusal is delivered only to the validated redirect URI as the closed OAuth error access_denied, with the request’s state and the issuer iss. No operator text or tenant identifier is placed in the redirect. This is the R377 shape used by the authorization flow.
Claim mapper contract
A mapper applies to both the id_token and userinfo. Its clients array is an explicit per-client disclosure decision, like groups_claim; omission means the client receives nothing. Organization-role and membership-organization sources require an organization-bound client.
The five sources are user_field (a declared key stored under publicMetadata), org_role, membership_orgs, bounded JSON static, and bounded string template. Templates accept only scalar {{user.<field>}} placeholders; they first read a built-in user field, then the declared publicMetadata key. Missing or non-scalar placeholders fail closed.
Mapper names may not collide with any claim the builders can emit: sub, name, picture, preferred_username, given_name, family_name, email, email_verified, auth_time, acr, groups, iss, aud, nonce, iat, or exp. Duplicate mapper names also stop boot. One mapped value is capped at 1024 UTF-8/serialized JSON bytes.
An unresolvable user-field, template, organization-role, or membership source fails closed and withholds the token. A missing optional custom user field omits that claim; it does not turn into JSON null or an empty authorization value.
Declared [user.fields] values arrive as top-level sign-up/update JSON keys, are type-checked before storage, and are persisted inside publicMetadata. A declaration marked required = true is required only on sign-up; omitting the entire section preserves the existing request contract.
: structurally, so a mapped value such as engineering:admin must not be replayed into a Proxmox role or path token without an explicit reversible encoding and a consumer-side fixture. A valid JSON string is not automatically valid application authorization input.Worked signet.toml examples
Refuse contractor password sign-ins, while documenting that allow cannot override the ordinary ban and lockout gates:
[[hooks.pre_sign_in]] when = { email_domain = "contractors.example.com", auth_method = "password" } action = "refuse" message = "contractor password access is disabled" [[hooks.pre_sign_in]] when = { email = "breakglass@example.com", auth_method = "password" } action = "allow"
Declare a typed metadata field, map it for one client, and attach an event annotation to passkey sign-ins:
[user.fields] department = { type = "string", max_length = 64, required = false } [[claims.mappers]] name = "department" source = "user_field" field = "department" clients = [{ client_id = "operations-console" }] [[hooks.post_sign_in]] when = { auth_method = "passkey" } action = "annotate" annotation = "strong-authentication"
Expose an organization role only to an explicitly organization-bound client:
[[claims.mappers]] name = "tenant_role" source = "org_role" clients = [{ client_id = "tenant-console", organization_id = "org_01HV..." }]