Zero Accessby Railmandocs

API & specs

HTTP endpoints, privilege helpers, client libraries, and M6 denylist.

API & specs

Status: Binding for implementers · Maturity: 0.0.x

Paths sit under your Better Auth basePath (often /api/auth). Package getting started: Packages overview.

Hard rule

Never unlock MEK because elevate succeeded. Never authorize from client-forged claims — verify on the server session row.

Composition sketch

const passkeyStack = enhancePasskey(passkey, {
  rpID,
  origin,
  assertCredentialAccess, // fn | "session" | "deny"
});

plugins: [
  ...passkeyStack.plugins,
  loginFactors(),
  elevate({
    deploymentMode: "single-instance",
    passkeyCounterGuard: passkeyStack.controller,
    passwordOnlyIfNoStronger: true,
    elevatedTtlSec: 300,
  }),
  zeroAccess({
    deploymentMode: "single-instance",
    assertAccess: assertZeroAccess,
  }),
];

Client BA plugins: passkeyClient(), elevateClient(), zeroAccessClient().


Elevate

MethodPathAuthBodyResponse
GET/elevate/methodssession{ available, defaultMethod, enrolled?, … } or elevate_no_method
GET/elevate/passkey/optionssessionWebAuthn authentication options
POST/elevate/verifysession{ method, password? | totpCode? | assertion? }{ ok, elevatedAt, expiresAt, amr, … }UI-only; claim lives on the session row

Verify methods

MethodStatus
passwordCredential hash
totpDecrypts twoFactor.secret + OTP (needs two-factor plugin data)
passkeyOptions + verifyAuthenticationResponse (rpID/origin from elevate or passkey stack)

Enrollment for policy is server-derived — never from the client body.

Signed claim

HMAC from HKDF(BETTER_AUTH_SECRET, salt="auth-elevate", info="elevate-claim-v1"). Wire: payload.sig. Fields: userId, elevatedAt, firstElevatedAt?, expiresAt / TTL, amr[], opaque sessionBinding. Default TTL 300s. Extending requires a new successful verify — no one-shot mint/consume API.

import { requireElevate } from "@railman/auth-elevate";

// Better Auth getSession bundle: claim lives on session.session
await requireElevate(session.session.elevateClaim, {
  secret,
  userId: session.user.id,
  sessionToken: session.session.token,
  ttlSec: 300,
});

Package docs: Elevate.


Zero-access

Wrap kinds: prf_passkey · password_kdf · recovery_bip39. Password/recovery wraps require ≥ 600_000 PBKDF2 iterations.

zeroAccess({ secret?: string }) is the only persistent key-authority option. Omit it to capture Better Auth's resolved secret, or set a stable Railman-specific root to separate persistent-data lifecycle. The root derives purpose-separated non-extractable MAC and storage-ID families internally. An installed passkey guard is discovered and bound automatically by exact plugin identity.

MethodPathAuthNotes
POST/zero-access/meksessionMeta + recovery wrap
GET/zero-access/meksessionAssociations
GET/zero-access/wrap-slotssessionList wraps (?mekId=)
POST/zero-access/wrap-slotssessionPut wrap (full-body M6)
POST/zero-access/wrap-slots/revokesessionRevoke
POST/zero-access/account-recovery/enrollsession + assertAccessMode B public key + possession proof
POST/zero-access/account-recovery/startnone*Challenge
POST/zero-access/account-recovery/finishnone*Verify + recovery session cookie
POST/zero-access/account-recovery/sessionrecovery cookie + originInspect the recovery session
POST/zero-access/account-recovery/registration/exchangerecovery cookie + originExchange for __Host-zero_access_recovery_registration
POST / callbackpasskey registration/finalizegrant cookie + originReserve, register, and finalize the recovery registration grant

* Rate-limited and purpose-scoped.

Package docs: Zero-access.

E2E relay

Session-gated; bodies M6-checked; persistent rows are MAC-protected.

MethodPathAuthNotes
PUT/zero-access/e2e/prekeysessionOwn bundle; identity change needs permit
GET/zero-access/e2e/prekey?userId=sessionAttested bundle
POST/zero-access/e2e/identity/rotate-requestsessionOne-time permit for newFp
POST/zero-access/e2e/inboxsessionDeliver envelope (contact-gated)
GET/zero-access/e2e/inbox?seq=sessionOwn inbox
POST/zero-access/e2e/contactssessionAdd contact
POST/zero-access/e2e/contacts/removesessionRemove
GET/zero-access/e2e/contactssessionList

Fingerprint: SHA-256("zero-e2e-fp-v1" ‖ signingKey ‖ identityKey) base64url. Client crypto: Zero-e2e.


M6 denylist

Reject bodies that include raw secret keys (NFKC + compacted, case-insensitive), including:

mnemonic, recoveryPhrase, rawMek, masterKey, prfSecret, plaintext, seed, privateKey, password, passphrase, authSecret, …

Wrap puts scan the full request body before snapshotting allowlisted fields. Canonical list: package m6.ts.


Privilege helpers

import { requireElevate } from "@railman/auth-elevate";
import {
  AUTH_REQUIRE_PRESETS,
  createElevateOpener,
  requireAccess,
} from "@railman/auth-login-factor";

await requireElevate(claim, { secret, userId, sessionToken });
await requireAccess({
  secret,
  session,
  map: AUTH_REQUIRE_PRESETS.privilegedAction,
  elevateOpener: createElevateOpener({
    secret,
    userId: session.userId,
    sessionToken: session.sessionToken,
    ttlSec: 300,
    maxElevatedSec: 3600,
  }),
});

requireAccess maps are server constants. Maps that inspect elevate AMR need elevateOpener from createElevateOpener (same user + session token). Browser MEK session helpers are never a server authorization signal.

Package docs: Login factor.


Client libraries

ConcernPackage / import
Passkey stack + PRF saltenhancePasskey · defineZeroAccessPasskeyCryptoConfigPasskey
Blind store BA client + low-level wraps@railman/auth-zero-access/clientZero-access
Product vault UXcreateVaultClientZero-vault
Sealed chat crypto@railman/zero-e2eZero-e2e
Elevate / login clients@railman/auth-elevate/client · stock passkey client

Vault client (surface)

MethodRole
createVaultInit MEK + recovery (+ daily wraps)
unlockDailyPRF and/or vault password
resetWithRecoveryPhraseBreak-glass
reenrollDailyMethodsAfter recovery

On this page