stable

Authentication

The strategy-free session-auth ladder: tier 1 (secret / getKeyring), tier-2 authenticators and on-* families, tier-3 quick-unlock, and auth introspection.

The session-authentication ladder is strategy-free. Unlike multi-user management (grant / revoke / rotate, which route through the opt-in team strategy and throw TeamNotEnabledError without it), every method below is always available on a plain createNoydb() session; the ones that touch a sensitive action are guarded by a policy gate, not by a with*() opt-in. Recovery — regaining access when factors are lost — has moved to its own page: Auth recovery.

The three tiers

Every vault is unlocked with a tier-1 secret; typing it on every read is not realistic, so noy-db layers two lighter-weight unlock methods on top of it and calls the resulting stack the three-tier privilege ladder. The ladder concept, factor packages, policy-gate DSL, and worked flows live on session-tiers — this page is the kernel-API index for the methods that enroll and use each tier, plus a dedicated look at how tier 1 is supplied and at why a method cannot serve a different tier.

Three concrete scenarios show the ladder in practice:

  • Daily password. A SaaS user signs in each morning with a routine tier-2 password (@noy-db/on-password) — a second secret, distinct from the rarely-typed tier-1 phrase — which unwraps the DEK set directly.
  • Biometric passkey. A laptop user unlocks with a tier-2 passkey — Touch ID / Face ID / Windows Hello via @noy-db/on-webauthn — with no secret typed at all.
  • PIN resume. After a short idle window, the app resumes from a tier-3 PIN (@noy-db/on-pin) over the DEKs still cached from the last unlock — it never re-derives the secret and cannot open a cold vault.

The three-tier capability matrix

Each tier is a distinct cryptographic capability, not an interchangeable label. The full picture — use Expand for a large-screen view:

The three-tier ladder
Capability / propertyTier 1 — root secretTier 2 — authenticateTier 3 — quick unlock
Opens a cold vaultyesyes — any one enrolled slotno — resume only
Session holds the KEKyes — derived from the secret (PBKDF2, 600k)no — the unlock yields the DEK set, kek: null (even webauthn / oidc wrap the DEKs, not the KEK)no — kek: null
Can grant / rotate / enroll / open another vault (KEK-requiring)yesno — blocked twice: no KEK, and activeTier 2 fails a minTier: 1 gateno
Can read / write recordsyesyes — it holds the unwrapped DEK setyes — over the cached DEKs
What its unlock yieldsthe KEK, which unwraps the DEKsthe DEK set directly (kek: null)the already-cached DEK map only
Where the factor livesuser memory, or a managed NoydbSealeron-* slots inside the keyring filein-memory state, TTL-bounded
Typical lifetimeenrollment, recovery, tier-1-gated actionsdays–weeks (app-defined)minutes (app-defined)
Packagecore @noy-db/hub — no on- packageon-password / on-webauthn / on-oidcon-pin
Survives secret rotationit is the secretdropped unless preserved via slotCeremoniesn/a — memory-only, expires on its TTL
When it can't unlock, fall back torecovery (paper / Shamir)another slot, or tier 1re-present tier 2

Higher tier outranks lower: only a higher tier can reset a lower one, and a policy gate's minTier is a floor — a tier-1 session can always do what a tier-2 gate allows, never the reverse.

The on-* method family

Every factor beyond the tier-1 secret ships as its own @noy-db/on-* package; the tier-1 secret itself is the one kernel-built credential. The whole catalog, by tier and integration level:

Auth methods — the on-* family and the kernel tier
MethodTier / roleFactorIntegrationWhat it does
secret → KEKTier 1Root secretKnowledge kernel The master secret; PBKDF2-SHA256 (600k) derives the KEK. Built into @noy-db/hub — no on- package.
on-passwordTier 2AuthenticatorKnowledge external pkg A separate frequently-typed password as a keyring slot wrapping the DEK set (LUKS-style); unlock yields kek: null.
on-webauthnTier 2Authenticator (passkey/biometric)Possession · Biometric external pkg Hardware-key / passkey unlock via WebAuthn PRF (rawId fallback); wraps the DEK map, not the KEK.
on-oidc
GoogleAppleLINEMicrosoftMetaAWS Cognito
Tier 2Authenticator (federated)Provider-set external pkg Federated login via a Bitwarden-style XOR key connector; the server never sees the KEK.
on-pinTier 3Quick unlockKnowledge external pkg Short-PIN warm resume of an already-unlocked session; cannot cold-open, never holds a KEK.
on-totp
Google AuthenticatorAuthy1PasswordMicrosoft Authenticator
2FAStep-up factorPossession external pkg RFC-6238 TOTP second factor a policy gate can demand; not an unlock.
on-email-otp2FAStep-up factorPossession external pkg Short-lived emailed codes via caller transport; not an unlock.
on-recoveryRecoveryRecovery (paper)Possession external pkg Printable one-time codes; last-resort unlock, burned after use.
on-shamirRecoveryRecovery (k-of-n)Possession ×N external pkg Splits the KEK into k-of-n shares; combining reconstructs the real KEK.
on-magic-linkViewerDelegated viewerPossession external pkg Single-use link opening a read-only viewer session, no secret; server-secret-bound HKDF.
on-threatOverlayThreat overlay— external pkg Lockout / duress-wipe / honeypot logic layered on unlock attempts; not a factor.

Factor — Knowledge (something you remember) · Possession (a device you hold) · Biometric (something you are) · Provider-set (chosen by the OIDC issuer) · — not a factor.

Tier 1 — the root secret

Tier 1 is the only credential that can open a cold vault — a fresh process with nothing cached. It establishes the session every lighter factor then re-opens, and it is built into @noy-db/hub core (there is no separate on-* package for it). There are three ways to supply it:

  • Owner secret (standard). Pass secret to createNoydb(); the hub derives the KEK from it via PBKDF2-SHA256 (600,000 iterations) and unwraps the collection DEKs (see Enclave). The secret lives only in the user's memory — the hub never stores it.
  • Self-generated sealed secret (managed mode). Set secretMode: 'managed' and supply a NoydbSealer: the hub mints a random 256-bit secret at first open and seals it under a key your own infrastructure holds — an at-* host such as an OS keychain or a cloud KMS — persisting only the sealed blob at _meta/sealed-secret. The user never types or sees a secret, which makes the vault coercion-resistant: there is nothing to reveal. Managed mode requires a strong (Shamir) recovery profile and disables rotateSecret (the secret is replaced only through recovery under a fresh sealing key). Full flow: session-tiers → managed mode.
  • Externally-supplied keyring (getKeyring). Skip the typed-secret model entirely: pass a getKeyring callback that returns an already-unlocked keyring produced by WebAuthn, OIDC, or Shamir. This is how a passwordless app puts a tier-2 factor at the front door — the factor supplies the keyring at cold start, so no secret is ever typed. (secret and getKeyring are mutually exclusive; bootstrapping a brand-new vault still needs a secret or recovery code once, to enroll the first factor, after which you switch to getKeyring.)

Secret strength is your choice; the tier is not. The tier of a factor is fixed by its cryptographic role (see below); the strength of the tier-1 secret is not enforced by default. validateSecret is opt-in at createNoydb() and grant() (default off), so secret: '1234' will open a vault and derive a real — if low-entropy — KEK. Pass validateSecret: true (or attach a SecretPolicy) to enforce the phrase-format floor. rotateSecret and recoverSecret are the exception: they validate the new phrase by default, so opt out with allowWeakSecret if a weak one is deliberate. Format rules and worked examples: session-tiers → strength validation.

Multi-owner tier 1

A vault is not limited to a single root secret. Each user has their own _keyring/<user> record holding the same collection DEKs wrapped under their own KEK — so several people can each hold an independent tier-1 secret that unlocks the same vault, and more than one can hold the owner role (grant(vault, { role: 'owner', … }); owners cannot be revoked). This is the standard remedy for single-owner bus-factor risk: grant() re-wraps the collection DEKs for the new user under a KEK derived from their secret — see Permissions & keyring.

Tier 2 — authenticators

Tier 2 is routine login — it replaces retyping the secret. It is multi-slot: a user enrolls one or more authenticator slots, each independently wrapping the same KEK (or, for on-password, the DEK set directly), and any one enrolled slot unlocks the vault. Adding or removing a slot is a constant-time keyring write; it never re-keys the DEKs. Enroll / remove / update each check the corresponding policy gate. Full walkthrough: session-tiers → Tier 2.

enrollAuthenticator()

enrollAuthenticator(vault: string, options: EnrollAuthenticatorOptions, factors?: FactorProofBundle): Promise<void>

Add a tier-2 authenticator slot to a vault's keyring. Takes the vault name, not a keyring — the hub checks the enroll-authenticator gate and fetches the active keyring internally.

  • vault — the vault whose keyring gains the slot.
  • options — an EnrollAuthenticatorOptions describing the slot: its caller-chosen id, method (webauthn / oidc / password), the wrap material produced by the matching @noy-db/on-* package, and method-specific meta.
  • factors — factor proofs presented to satisfy the enroll-authenticator gate when a tightened policy demands them.

removeAuthenticator()

removeAuthenticator(vault: string, slotId: string, factors?: FactorProofBundle): Promise<void>

Remove a tier-2 slot by id. Idempotent — removing a missing slot is a no-op. Gated by remove-authenticator.

  • vault — the vault whose keyring loses the slot.
  • slotId — the id of the slot to remove.
  • factors — factor proofs presented to satisfy the remove-authenticator gate.

listAuthenticators()

listAuthenticators(vault: string): Promise<ReadonlyArray<KeyringAuthenticator>>

List the enrolled tier-2 slots on a vault's keyring. Each entry is a KeyringAuthenticator — a discriminated union of wrap-KEK slots (webauthn, oidc) and wrap-DEKs slots (password).

  • vault — the vault whose slots to list.

updateAuthenticator()

updateAuthenticator(vault: string, slotId: string, options: UpdateAuthenticatorOptions, factors?: FactorProofBundle): Promise<void>

Rename or re-label a slot's meta — its id, method, and wrap material are immutable. Gated by update-authenticator; at least one meta field must be provided.

  • vault — the vault whose slot to update.
  • slotId — the id of the slot to relabel.
  • options — an UpdateAuthenticatorOptions carrying the replacement meta.
  • factors — factor proofs presented to satisfy the update-authenticator gate.

enrollWebAuthn()

enrollWebAuthn(vault: string, ceremony: (keyring: UnlockedKeyring) => Promise<EnrollAuthenticatorOptions>, factors?: FactorProofBundle): Promise<{ credentialId: string }>

Native-WebAuthn enrollment helper. Runs the caller's ceremony against the unlocked keyring to produce the slot options (whose method must be webauthn and whose meta.credentialId must be present), then persists the slot. Gated by enroll-authenticator like enrollAuthenticator() itself.

  • vault — the vault whose keyring gains the WebAuthn slot.
  • ceremony — a callback receiving the UnlockedKeyring and returning EnrollAuthenticatorOptions; typically delegates to enrollWebAuthn() from @noy-db/on-webauthn.
  • factors — factor proofs presented to satisfy the enroll-authenticator gate.

listWebAuthnSlots()

listWebAuthnSlots(vault: string): Promise<ReadonlyArray<{ id: string; enrolledAt: string; credentialId: string }>>

List the enrolled slots scoped to the webauthn method — each with its slot id, enrolledAt timestamp, and credentialId.

  • vault — the vault whose WebAuthn slots to list.

unlockViaAuthenticator()

unlockViaAuthenticator(vault: string, slotId: string, verify: (slot: KeyringAuthenticator) => Promise<UnlockedKeyring>): Promise<UnlockedKeyring>

Open a session via an enrolled slot. The verify callback receives the stored slot and returns the unlocked keyring — typically delegating to the matching @noy-db/on-* package; subsequent checkGate() calls then see a tier-2 session.

  • vault — the vault to unlock.
  • slotId — the id of the enrolled slot to unlock with.
  • verify — a callback receiving the KeyringAuthenticator and returning the UnlockedKeyring.

Tier-2 on-* families

The wrap material for each slot comes from an @noy-db/on-* package — a crypto primitive only; storage, rate-limiting, and UI belong to the caller. The three families that serve tier 2:

  • on-password — a routine tier-2 login password, a separate secret from the tier-1 master phrase (PBKDF2-SHA256, wrap-DEKs slot).
  • on-webauthn — hardware-key / platform-passkey unlock via WebAuthn (Touch ID, Face ID, Windows Hello, YubiKey); the tier-2 method with no knowledge factor.
  • on-oidc — split-key OIDC bridge (half the KEK on the device, half from the provider's key connector); requires operating a key-connector server.

Federated trust (on-oidc). When you unlock via OIDC, the provider — Google, Apple, LINE, Microsoft, Meta, AWS Cognito, any OIDC issuer — owns the identity proof: the email-and-password, emailed-code, or biometric ceremony all happen at the provider, and noy-db only consumes the resulting ID token, so the provider's MFA strength flows into the tier-2 unlock for free. The provider does not own your key — on-oidc XOR-splits the KEK into a device half (never leaves the device) and a server half (released after login), so a compromised provider still cannot decrypt. Full trust model: on-oidc → what the provider is responsible for.

Not tier-2 authenticators, but sitting alongside the ladder: the step-up 2FA factors on-totp, on-email-otp, on-recovery, on-shamir, and roaming on-webauthn supply fresh off-device proofs that policy gates can additionally demand (a fresh proof each time a gate asks); and on-magic-link is a delegated one-shot viewer session, not a session tier. See the on family catalog.

Tier 3 — quick unlock

Tier 3 is quick-resume after idle. It re-opens an already-unlocked session within a bounded idle window over the DEKs cached from a tier-1 or tier-2 unlock — it cannot open a cold vault, and on idle expiry the cache is wiped and tier 2 must be re-presented. It is never safe on a shared workstation, where the cached DEKs live on the device for the whole window. Full walkthrough: session-tiers → Tier 3.

enrollUnlock()

enrollUnlock(vault: string, state: QuickUnlockState, factors?: FactorProofBundle): Promise<void>

Register a tier-3 quick-unlock state produced by an on-* tier-3 primitive (e.g. enrollPin() from @noy-db/on-pin).

  • vault — the vault to register quick-unlock for.
  • state — the QuickUnlockState minted from an already-unlocked keyring.
  • factors — factor proofs presented if the rotate-unlock gate demands them.

unlockViaPin()

unlockViaPin(vault: string, resume: (state: QuickUnlockState) => Promise<UnlockedKeyring>): Promise<UnlockedKeyring | undefined>

Resume a session via the registered tier-3 state. Resolves to undefined when no tier-3 state is registered for the vault; the resume callback typically delegates to resumePin() from @noy-db/on-pin, which throws past its TTL / attempt limit.

  • vault — the vault to resume.
  • resume — a callback receiving the stored QuickUnlockState and returning the UnlockedKeyring.

clearQuickUnlock()

clearQuickUnlock(vault: string): void

Synchronously drop the registered tier-3 quick-unlock state for a vault.

  • vault — the vault whose quick-unlock state to clear.

Tier-3 on-* family

  • on-pin — the canonical tier-3 primitive: a session-resume PIN (or biometric quick-lock) that re-unlocks cached DEKs without re-typing the secret. It never replaces the secret; it only resumes an already-unlocked session.

Can a method serve a different tier?

No — you cannot reassign a method's tier. A factor's tier is pinned by what its unlock yields, not by any setting: tier 1 yields the KEK; tier 2 and tier 3 yield only the DEK set (kek: null). You cannot promote on-pin into the cold-unlock primary — it holds no KEK, and its resume state must be minted from an already-unlocked session, so it structurally cannot open a cold vault.

But the tiers compose — a lighter factor can be the only thing a user ever presents, with a higher tier embedded behind it. Three shapes matter:

Authenticate with only a tier-2 method. You can run a vault where no secret is typed at runtime, two ways:

  • getKeyring — pass a callback that returns an unlocked keyring from a WebAuthn / OIDC ceremony. openVault treats this as a tier-1 session (most-privileged), so minTier policy gates pass — but the keyring it returns still carries kek: null, so KEK-requiring operations still cannot run.
  • managed mode (secretMode: 'managed' + a NoydbSealer) — the hub self-generates a 256-bit secret and seals it under a key your infrastructure holds; at cold start the provider auto-unseals it, so the user types nothing and the session is a full tier 1 with a real KEK.

Either way, bootstrapping the first enrollment needs a real secret or recovery code once; after that the factor (or the provider) is the only thing presented.

What a tier-2 session can and cannot do. A tier-2 authenticator unlock (on-password / on-webauthn / on-oidc) always returns kek: null — it hands you the DEK set, not the key that wraps it. So the session can read and write every collection it unwrapped, but cannot grant, rotate the secret, enroll another authenticator, or open another vault: those need the KEK, and on an activeTier: 2 session they are additionally denied by the minTier: 1 policy gate. There is no session step-up API — to reach tier 1 you re-open with the real secret (or getKeyring / managed mode). This is why a machine job that must grant or rotate runs under a real tier-1 secret (typically managed mode), never a tier-2 slot.

Pair a tier-3 PIN for fast resume. Enroll on-pin after any unlock and the app resumes from the PIN within an idle window over the cached DEKs — a convenience layered on top of tier 1 or tier 2, never a replacement for either.

"PIN-only" apps — which lever you are actually pulling. If you want the user to only ever type a short PIN, exactly one composition delivers it across cold starts: use the PIN as the tier-1 secret itself. Validation is opt-in, so secret: '1234' opens a vault and derives a real — if low-entropy — KEK; the same PIN then works for setup, cold unlock, and resume, because it is the master secret. Its low entropy becomes the vault's real strength floor, so pair it with your own rate-limiting or on-threat lockout.

What you cannot do is get "only a PIN" out of on-pin: under managed mode the user types nothing at cold start (the provider unseals), and on-pin merely speeds a warm resume within a live session. So "unlock with just a PIN" is either a weak tier-1 secret (the PIN is the key) or managed mode (the user types nothing) — never the resume PIN acting as a cold primary.

Combining methods — alternatives and 2FA

Two compositions get confused — one is or, the other is and.

Alternatives — any one unlocks (the multi-slot model). Enroll several tier-2 authenticators on one keyring; any single one opens the vault, so a user isn't locked out when they're away from one device. The slots are peers — adding or removing one is a constant-time keyring write that never re-keys the DEKs.

// Enroll two independent tier-2 slots on the same vault (wrap material comes from the on-* packages)
await db.team.enrollAuthenticator('acme', { id: 'passkey', method: 'webauthn', wrapped_kek, meta })
await db.team.enrollAuthenticator('acme', { id: 'work-pw', method: 'password', wrapped_deks, iv, meta })

// Unlock with EITHER — whichever the user has to hand:
await db.team.unlockViaAuthenticator('acme', 'passkey', verifyPasskey)   // on the laptop
await db.team.unlockViaAuthenticator('acme', 'work-pw', verifyPassword)  // or later, elsewhere

Two factors together — 2FA (the policy gate). The unlock is one factor; a policy gate demands a second, fresh factor before a sensitive action. This is the and: unlock with your passkey (something you have), then prove a TOTP code (something on your phone) before the vault will export.

import { createNoydb, PERSONAL_POLICY, PolicyDeniedError } from '@noy-db/hub'

const db = await createNoydb({
  store, user: 'alice', secret,
  policy: {
    ...PERSONAL_POLICY,
    gates: {
      ...PERSONAL_POLICY.gates,
      // exporting plaintext now needs a tier-2 session PLUS a fresh TOTP proof
      'export-plaintext': { minTier: 2, factors: [{ anyOf: ['totp'], freshnessMs: 5 * 60_000 }] },
    },
  },
})

// …unlock (tier 2) as usual, then present the second factor at the sensitive action:
try {
  await db.checkGate('acme', 'export-plaintext', {
    factors: [{ kind: 'totp', payload: { code: '123456' }, mintedAt: new Date().toISOString() }],
  })
  // …run the export
} catch (e) {
  if (e instanceof PolicyDeniedError) {
    // e.reason: 'insufficient-tier' | 'missing-factor' | 'stale-proof' | …
  }
}

Require two distinct factors (true 2-of-N) — set count on the requirement:

'rotate-secret': { minTier: 1, factors: [{ anyOf: ['totp', 'email-otp', 'recovery'], count: 2 }] }
// the gate now needs TWO different factors from the list, each freshly proven

The mental model: alternatives (or) are multiple tier-2 slots; combined 2FA (and) is a policy gate's factors requirement layered on the unlock. A gate can demand count distinct factors within a freshnessMs window; the full DSL, factor kinds, and shipped presets live on session-tiers → policy gates.

Auth introspection

Read-only views of a vault's auth setup: a plain-English summary, a Mermaid diagram of the tier model, and per-user (or bulk) enrollment summaries. The two vault-wide surfaces are ungated; the per-user surfaces are guarded by the view-user-auth gate (disabled by default) and pass their output through a strict field allowlist. Full treatment: auth-introspection.

describeAuthConfig()

describeAuthConfig(vault: string): Promise<string>

A plain-English summary of the vault's auth configuration — enrolled tiers, slots, and recovery profiles. Ungated.

  • vault — the vault to describe.

diagramAuthConfig()

diagramAuthConfig(vault: string): Promise<string>

A Mermaid flowchart TB of the vault's tier model, for UI rendering. Ungated.

  • vault — the vault to diagram.

describeUserAuth()

describeUserAuth(vault: string, userId: string, factors?: FactorProofBundle): Promise<string>

A per-user enrollment summary, passed through a strict field allowlist. Guarded by the view-user-auth gate (disabled by default).

  • vault — the vault the user belongs to.
  • userId — the principal whose auth to describe.
  • factors — factor proofs presented to satisfy the view-user-auth gate.

describeAllUsersAuth()

describeAllUsersAuth(vault: string, factors?: FactorProofBundle): Promise<Array<{ userId: string; description: string }>>

The bulk variant of describeUserAuth — one allowlisted description per user. Guarded by the view-user-auth gate.

  • vault — the vault whose users to describe.
  • factors — factor proofs presented to satisfy the view-user-auth gate.