Zero Accessby Railmandocs

Quick start

Try the demo, then compose zero-access plugins into your Better Auth app.

Quick start

Not every app needs the full vault. Start with Use cases or the Packages overview, then come back here for the production-shaped sketch. HTTP reference: API & specs.

1. Try the live lab

On this host (shared app shell):

  • Elevate — step-up only (password / TOTP / optional passkey; no vault)
  • Vault — full ceremony (create → unlock → seal → recovery)
  • Chat — E2E messaging layered on the Vault identity

New to the vocabulary? Read Background first.

Local monorepo:

pnpm install
pnpm docs

2. Install (@railman/*)

Packages live in railmanio/auth-zero-access under the @railman scope. They are still private: true — link the workspace or install from this git repo:

pnpm add @railman/auth-zero-access @railman/auth-zero-access-passkey \
  @railman/auth-elevate @railman/auth-login-factor @railman/zero-vault
# after the Vault path, add Chat:
# pnpm add @railman/zero-e2e

3. Server composition (production sketch)

PRF salt lives in the browser crypto config — not on the server passkey plugin. Lab/dev may use single-instance; do not ship that. Smaller compositions: Use cases and the compose guides.

import { betterAuth } from "better-auth";
import { passkey } from "@better-auth/passkey";
import { enhancePasskey } from "@railman/auth-zero-access-passkey";
import {
  createZeroAccessAssertAccess,
  zeroAccess,
  zeroAccessSecurityHeaders,
} from "@railman/auth-zero-access";
import { elevate, requireElevate } from "@railman/auth-elevate";
import { loginFactors } from "@railman/auth-login-factor";

const secret = process.env.BETTER_AUTH_SECRET!;

// Stock-compatible options in; paired [stock passkey, guard] plugins out.
const passkeyStack = enhancePasskey(passkey, {
  rpID: "example.com",
  origin: ["https://example.com"],
  // Integrate with "session"; ship a step-up assertion before production.
  assertCredentialAccess: "session",
});

export const auth = betterAuth({
  secret,
  // secondaryStorage: shared DO / Redis / KV — required for multi-instance
  session: { cookieCache: { enabled: false } },
  plugins: [
    ...passkeyStack.plugins,
    loginFactors(),
    elevate({
      deploymentMode: "multi-instance",
      passkeyCounterGuard: passkeyStack.controller,
      passwordOnlyIfNoStronger: true,
      elevatedTtlSec: 300,
    }),
    zeroAccess({
      deploymentMode: "multi-instance",
      // After BA advanced.ipAddress + blocking direct origin, graduate with:
      // securityLevel: "strict", recoveryIpAddressTrust: "trusted-edge",
      // Optional separate root: secret: process.env.ZERO_ACCESS_SECRET,
      requireRecoveryExportAck: true,
      e2eRequireRecipientContact: true,
      assertAccess: createZeroAccessAssertAccess({
        isElevated: async (ctx) => {
          await requireElevate(ctx.session.session.elevateClaim, {
            secret,
            userId: ctx.userId,
            sessionToken: ctx.session.session.token,
          });
          return true;
        },
      }),
    }),
  ],
});

const headers = zeroAccessSecurityHeaders({ hsts: true });

Normal setup needs no child-authority or passkey-pairing configuration. zeroAccess() discovers the installed guard by exact plugin identity and captures Better Auth's resolved secret once (or use zeroAccess({ secret }) for a separate persistent-data lifecycle).

For action maps that inspect elevate AMR (requireAccess + AUTH_REQUIRE_PRESETS), wire createElevateOpener — see Passkey + step-up and Login factor.

Core plugins pin the exact reviewed Better Auth release (1.6.28 today). Compositions here are typechecked as docs-site/verified-examples/* (pnpm docs:verify-examples).

4. Client vault (product UX)

import { defineZeroAccessPasskeyCryptoConfig } from "@railman/auth-zero-access-passkey/client";
import { createVaultClient } from "@railman/zero-vault";

const cryptoConfig = defineZeroAccessPasskeyCryptoConfig({
  salt: process.env.NEXT_PUBLIC_PRF_SALT!, // tenant-stable public salt
});

const vault = createVaultClient({
  userId: session.user.id,
  passkeyOptions: cryptoConfig,
  onPrfUnavailable: (msg) => toast.error(msg),
});

// Create: recovery phrase + optional daily password / PRF
const created = await vault.createVault({ password: vaultPassword });
created.init.exportRecoveryPhrase(); // force user export

// Persist wraps via your session-authenticated fetch to /zero-access/*
// (POST mekBody + dailySlots)

// Daily unlock — never use the recovery phrase here
await vault.unlockDaily({ slots, prfSecret, password });

5. Add Chat after Vault

Chat does not run a second WebAuthn ceremony. Re-open the Vault briefly for a grant operation, derive the identity seed from that grant-capable MEK, then hand the seed to zero-e2e:

import { deriveE2eIdentitySeed } from "@railman/zero-vault";
import { identityKeyPairFromSeed, zeroize } from "@railman/zero-e2e";

const identity = await vault.withGrantUnlock(
  { slots, prfSecret, password },
  async (grant) => {
    const seed = await deriveE2eIdentitySeed(grant);
    try {
      return identityKeyPairFromSeed(seed);
    } finally {
      zeroize(seed);
    }
  }
);

This API boundary keeps zero-e2e independent from browser ceremony code while the product path remains passkey → PRF-backed Vault unlock → identity seed → Chat.

Guides

Hard rules (do not skip)

  1. Elevate ≠ MEK unlock — keep the code paths separate.
  2. Never send MEK, mnemonic, or PRF secrets to the server (M6 denylist).
  3. Authorize from the server session, not from client-forged elevate claims.
  4. Recovery phrase is for reset, not everyday unlock.
  5. Shared stores for multi-instance — do not ship single-instance; use an explicit zeroAccess.secret only when you need a separate persistent-data lifecycle.

On this page