0029. Extensibility: actions/hooks and the execution model
Context
Running custom logic in the authentication pipeline (pre-registration, pre-login, pre-token, post-login) — Auth0 "Actions", Okta "Workflows" — is one of the biggest differentiators of the incumbents. Declarative claim mapping (proposal 0008) covers the common case safely, but it deferred the hard question to here: where and how does arbitrary, tenant-supplied code run without turning the identity system into a remote-code-execution risk or violating the constitution.
Decision
-
Two tiers, declarative-first. The default extension surface is declarative (claim mapping, proposal 0008; policy rules) — no code execution, no sandboxing risk, and it covers most needs. Code extension is a separate, opt-in capability for the minority that declarative cannot express.
-
Code runs in a WebAssembly sandbox, not a general runtime. Tenant extension code is compiled to Wasm and executed in a capability-scoped, resource-limited, deterministic sandbox (a mature engine such as Wasmtime): no ambient network or filesystem, only explicit host functions, with CPU / memory / wall-clock budgets. This is the same doctrine as ADR-0006 — consume a hard, well-understood primitive (the Wasm sandbox) rather than invent one. One host function is a deliberate exception, because the dominant incumbent Action use case is "call an external API during login": an outbound-HTTP host function, allowlisted per extension at registration (named hosts only), time-boxed within the extension's wall-clock budget, and with responses size-capped (proposal 0006). Without it the code tier cannot express the use case that justifies its existence; with ambient network it would be an SSRF engine — the allowlist is the line between the two.
-
Extensions obey the constitution. Code runs only on the issuance side (Modgud, ADR-0009), never on the Vör verification hot path (rule 1) — a verification never executes tenant code. Issuance-side extensions may touch the core (they are issuance, not the hot path), but are time-boxed. Failure policy is per hook point, not global: hooks that gate or shape the result (pre-login policy, pre-token claim mutation) fail closed — a failing extension fails that login, never mints a less-constrained token; hooks that merely observe (post-login notification) fail open — their failure is logged and never blocks the ceremony. A pre-token hook's output is validated after the hook: the mutated claim set must still conform to the registered claim schema (proposal 0008) and the token format (ADR-0003) — extension output is input, not trusted material.
-
Webhooks are the no-code async alternative (proposal 0009) for "notify / react" cases that do not need to alter the in-flight result. Webhooks are a consumer of the identity event stream (ADR-0022) — a delivery projection with signatures and retries, not a second, parallel event mechanism; the one-stream rule of ADR-0022 holds by construction.
-
Per-tenant isolation (rule 3): an extension executes in its tenant's context, sandboxed from other tenants and from the host.
-
The extension ladder — and what is deliberately not on it. Vord extends on exactly three rungs, ordered by trust:
- Rung 1 — configuration and declarative rules (realm policy, claim mapping — proposal 0008): the tenant surface, no code.
- Rung 2 — sandboxed Wasm at the named hook points (decisions 2–3, 7): tenant code, capability-scoped.
- Rung 3 — trait implementation at build time (the ADR-0013 seams:
AuthenticationMethod,KeyCustody,Authorizer,ChallengeProvider, the notification channels, …): the operator/contributor surface — extension by Cargo composition, an operator builds avord-servercarrying additional method or backend crates (ADR-0015 decision 5 states the method contract).
Dynamic native plugin loading is declined — no
dlopen, no drop-in shared objects, no runtime-discovered extension jars. A security product whose executing code cannot be enumerated from its build manifest (Cargo.lock) is un-auditable, and the Wasm rung exists precisely so runtime flexibility never requires native code. The Keycloak provider-SPI model is the named anti-pattern here: maximal deploy-time flexibility, paid for in unauditable deployments. Declined with the same finality as ADR-0014's declines. -
The named hook points — and the rule for adding one. The code tier (rung 2) attaches only at named points, each carrying the failure class of decision 3:
- pre-registration — gate or shape a signup (custom validation, fraud enrichment beyond proposal 0021's built-ins): fails closed;
- post-authentication / pre-issuance — gate a login after the credential ceremony succeeds, demand step-up (ADR-0020): fails closed;
- pre-token — mutate or enrich claims within the registered schema: fails closed, output validated after the hook (decision 3);
- post-login / post-registration observers — notify, record: fail open. Anything asynchronous beyond these is a webhook (decision 4). A new hook point is added by amending this record with its failure class and its read/mutate surface — never by ad-hoc callback growth; and no hook point will ever sit on the Vör path (decision 3's constitutional line, restated because hook catalogs grow).
Consequences
- The incumbents' headline differentiator is matched without making the IdP an RCE surface: the hard part (safe execution of untrusted code) is a known primitive (Wasm), consumed, not reinvented.
- Declarative-first keeps the overwhelming majority of customizations code-free; code is the escape hatch, sandboxed.
- Extensions never touch the verification hot path, so rule 1 and the edge-independence property (ADR-0010) are untouched by extensibility.
- This resolves the deferral flagged in proposal 0008. It is reserved / roadmap — the decision (Wasm-sandboxed, issuance-side, declarative-first) is recorded now so the declarative path and the future code path compose; the build is sequenced later.