Zero Accessby Railmandocs
Compose

Vault composition

Blind MEK store plus create/unlock UX on the hardened passkey stack.

Vault composition

Use this when the product needs a zero-access vault: the server stores only blind wraps; the MEK, mnemonic, and PRF secret stay in the browser.

Vault requires the passkey composition. Elevate is usually installed too so MEK/wrap writes and recovery control can demand a fresh step-up window.

Live: Vault lab · Chooser: Use cases · Ceremony guide: Vault

What you install

Everything from Passkey + step-up, plus:

PackageRole
@railman/auth-zero-accessBlind store plugin (zeroAccess) + E2E relay endpoints
@railman/zero-vaultProduct vault client (not a Better Auth plugin)

PRF salt is browser-only via defineZeroAccessPasskeyCryptoConfig — never a server plugin option.

Server composition

import { betterAuth } from "better-auth";
import { passkey } from "@better-auth/passkey";
import { twoFactor } from "better-auth/plugins";
import { enhancePasskey } from "@railman/auth-zero-access-passkey";
import { zeroAccess } from "@railman/auth-zero-access";
import { elevate } from "@railman/auth-elevate";
import {
  AUTH_REQUIRE_PRESETS,
  createElevateOpener,
  loginFactors,
  requireAccess,
} from "@railman/auth-login-factor";

const secret = process.env.BETTER_AUTH_SECRET!;

const passkeyStack = enhancePasskey(passkey, {
  rpID: "example.com",
  origin: ["https://example.com"],
  assertCredentialAccess: "session",
});

export const auth = betterAuth({
  secret,
  session: { cookieCache: { enabled: false } },
  plugins: [
    ...passkeyStack.plugins,
    loginFactors(),
    twoFactor(),
    elevate({
      deploymentMode: "single-instance",
      passkeyCounterGuard: passkeyStack.controller,
      passwordOnlyIfNoStronger: true,
      elevatedTtlSec: 300,
      maxElevatedSec: 3600,
    }),
    zeroAccess({
      deploymentMode: "single-instance",
      requireRecoveryExportAck: true,
      // Optional separate Railman root:
      // secret: process.env.ZERO_ACCESS_SECRET,
    }),
  ],
});

export async function assertPrivileged(session: {
  userId: string;
  sessionToken: string;
  loginFactor?: string | null;
  elevateClaim?: string | null;
}) {
  await requireAccess({
    secret,
    session,
    map: AUTH_REQUIRE_PRESETS.privilegedAction,
    elevateOpener: createElevateOpener({
      secret,
      userId: session.userId,
      sessionToken: session.sessionToken,
      ttlSec: 300,
      maxElevatedSec: 3600,
    }),
    // enrolled: { passkey, totp, password }, // server lookup for password AMR
  });
}

zeroAccess() discovers the installed passkey guard by exact plugin identity. Omit secret to capture Better Auth's resolved secret; set a stable Railman root only when persistent-data lifecycle must diverge from sessions.

Lab/dev may omit assertAccess at standard (warning); protected writes still deny without a callback. Production sketch with createZeroAccessAssertAccess: Quick start.

Typechecked sources: dx-d-full-vault.ts, dx-client-vault.ts.

Client plugins + vault

import { createAuthClient } from "better-auth/client";
import { passkeyClient } from "@better-auth/passkey/client";
import { elevateClient } from "@railman/auth-elevate/client";
import { zeroAccessClient } from "@railman/auth-zero-access/client";
import { defineZeroAccessPasskeyCryptoConfig } from "@railman/auth-zero-access-passkey/client";
import { createVaultClient } from "@railman/zero-vault";

export const authClient = createAuthClient({
  plugins: [passkeyClient(), elevateClient(), zeroAccessClient()],
});

const cryptoConfig = defineZeroAccessPasskeyCryptoConfig({
  salt: process.env.NEXT_PUBLIC_PRF_SALT!,
});

export const vault = createVaultClient({
  userId,
  passkeyOptions: cryptoConfig,
});

Create / unlock / recovery UX: Vault guide. Low-level wrap helpers live under @railman/auth-zero-access/client — prefer createVaultClient for product paths.

HTTP

Blind store and recovery endpoints: API — Zero-access. Never send MEK, mnemonic, or PRF secrets in request bodies (M6).

Hard rules

  • Elevate (L1) MEK unlock (L2) — keep the code paths separate
  • Recovery phrase is for reset, not everyday unlock
  • Authorize from the server session, not client-forged claims

Next

Demo-only: deploymentMode: "single-instance". Do not ship process-local stores.

On this page