Zero-access kit
Getting started with @railman/auth-zero-access-kit — one-import composition of the whole Zero Access suite.
Getting started — Zero-access kit
@railman/auth-zero-access-kit is the composition seam of the suite: one call installs the four plugin packages in order and wires every cross-package join for you. It owns wiring only — no crypto, no endpoints, no claims.
Layer rule
The kit does not change security semantics — it enforces the same fail-closed
posture as the manual composition (docs/KIT.md in the repo): elevate never
unlocks the vault, and claims are always server-signed.
Server — one call
import { createZeroAccessKit } from "@railman/auth-zero-access-kit";
const kit = createZeroAccessKit({
relyingParty: { rpID: "example.com", origins: ["https://example.com"] },
securityLevel: "strict",
secret: process.env.ZERO_ACCESS_SECRET, // persistent key authority root
});
export const auth = betterAuth({
plugins: kit.plugins, // passkey, guard, login-factors, elevate, zero-access
});That replaces the manual wiring of enhancePasskey + controller injection + createZeroAccessAssertAccess — see guides for what each plugin contributes.
Fail-closed defaults
relyingPartyis required — no implicit WebAuthn identity.assertCredentialAccessdefaults to"deny"— configure it before enabling credential management ("session"for labs, a custom assertion for production).- The default
assertAccessgate requires a fresh elevate window for MEK/wrap writes, the recovery control plane, and E2E identity rotation. Providesecret(the same HMAC root elevate claims are bound to) or your ownassertAccess. - Unknown options are rejected at construction.
Options pass-through
Per-plugin options stay namespaced:
createZeroAccessKit({
relyingParty,
secret,
loginFactors: { stampOnSessionCreate: true },
elevate: {
allowPasskey: true, // kit injects the controller + rpID/origin
requireDistinctInitialFactor: true,
elevatedTtlSec: 600,
},
zeroAccess: {
requireRecoveryExportAck: true,
e2eRequireRecipientContact: true,
},
// assertAccess: async (ctx) => { /* custom policy */ }
});Privilege gates
Gates bind { secret, userId, sessionToken } once per request so route handlers stop repeating the triple:
const sessionGates = kit.gates(secret).forSession({
userId: session.user.id,
sessionToken: session.session.token ?? session.session.id,
loginFactor: session.session.loginFactor, // raw L0
elevateClaim: session.session.elevateClaim, // raw L1
});
await sessionGates.requireElevate(session.session.elevateClaim, {
amrAnyOf: ["passkey", "totp"],
});
const gate = await sessionGates.requireAccess({
map: AUTH_REQUIRE_PRESETS.privilegedAction,
enrolled: await loadEnrollment(userId), // server-looked-up
});Browser — one call
import { createAuthClient } from "better-auth/client";
import {
createVaultClient,
createZeroAccessClientKit,
} from "@railman/auth-zero-access-kit/client";
const clientKit = createZeroAccessClientKit({
prfSalt: process.env.NEXT_PUBLIC_PRF_SALT!, // browser-only tenant salt
});
// Handles keep exact action inference for createVaultClient.
export const authClient = createAuthClient({
plugins: [
clientKit.passkeyClientPlugin,
clientKit.elevateClientPlugin,
clientKit.zeroAccessClientPlugin,
],
});
export const vault = createVaultClient({
userId: session.user.id,
cryptoConfig: clientKit.cryptoConfig,
authClient,
});Client plugins are HTTP helpers only — they never unlock MEK or authorize. Client boundaries.
Inspection
kit.inspect(); // securityLevel, pluginOrder, passkeyPosture,
// assertAccessMode: "elevate-gated" | "custom"
kit.controller; // exact counter controller wired into elevateConfig-driven composition — composeZeroAccess()
For partial stacks and custom combinations — step-up only, passkey login only, vault without chat — the feature-flag engine renders exactly the plugins you enable, plus native Better Auth plugins at canonical slots:
import { twoFactor } from "better-auth/plugins";
import { composeZeroAccess } from "@railman/auth-zero-access-kit";
const stack = composeZeroAccess({
securityLevel: "strict",
secret: process.env.ZERO_ACCESS_SECRET,
relyingParty: { rpID: "example.com", origins: ["https://example.com"] },
features: {
elevate: { passkey: true, allowTotp: true },
vault: { requireRecoveryExportAck: true },
chat: { requireRecipientContact: true },
},
stock: { betweenLoginFactorAndElevate: [twoFactor()] },
});
export const auth = betterAuth({
plugins: [...stack.plugins], // native Better Auth plugins
});
export const authClient = createAuthClient({
plugins: [...stack.clientPlugins], // only enabled features' clients
});Every feature defaults to off and accepts true / false / options. Dependencies resolve automatically (chat enables vault; elevate enables login-factors; passkey step-up joins when a relying party is present) and contradictions fail at construction with explicit messages. Inspect the resolved composition any time:
stack.inspect();
// features: { passkey, loginFactor, elevate, vault, chat }
// pluginOrder / clientOrder / diagnosticscreateZeroAccessKit stays as the kitchen-sink preset — composeZeroAccess is the general form.
When NOT to use the kit
- Different
securityLevelper plugin — the kit enforces one posture. - Partial stacks (elevate alone, or passkey login without zero-access) — use
composeZeroAccess()or the package guides.