Zero Accessby Railman
Packages

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

  • relyingParty is required — no implicit WebAuthn identity.
  • assertCredentialAccess defaults to "deny" — configure it before enabling credential management ("session" for labs, a custom assertion for production).
  • The default assertAccess gate requires a fresh elevate window for MEK/wrap writes, the recovery control plane, and E2E identity rotation. Provide secret (the same HMAC root elevate claims are bound to) or your own assertAccess.
  • 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 elevate

Config-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 / diagnostics

createZeroAccessKit stays as the kitchen-sink preset — composeZeroAccess is the general form.

When NOT to use the kit

  • Different securityLevel per plugin — the kit enforces one posture.
  • Partial stacks (elevate alone, or passkey login without zero-access) — use composeZeroAccess() or the package guides.

On this page