Authentication
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:
| Capability / property | Tier 1 — root secret | Tier 2 — authenticate | Tier 3 — quick unlock |
|---|---|---|---|
| Opens a cold vault | yes | yes — any one enrolled slot | no — resume only |
| Session holds the KEK | yes — 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) | yes | no — blocked twice: no KEK, and activeTier 2 fails a minTier: 1 gate | no |
| Can read / write records | yes | yes — it holds the unwrapped DEK set | yes — over the cached DEKs |
| What its unlock yields | the KEK, which unwraps the DEKs | the DEK set directly (kek: null) | the already-cached DEK map only |
| Where the factor lives | user memory, or a managed NoydbSealer | on-* slots inside the keyring file | in-memory state, TTL-bounded |
| Typical lifetime | enrollment, recovery, tier-1-gated actions | days–weeks (app-defined) | minutes (app-defined) |
| Package | core @noy-db/hub — no on- package | on-password / on-webauthn / on-oidc | on-pin |
| Survives secret rotation | it is the secret | dropped unless preserved via slotCeremonies | n/a — memory-only, expires on its TTL |
| When it can't unlock, fall back to | recovery (paper / Shamir) | another slot, or tier 1 | re-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:
| Method | Tier / role | Factor | Integration | What it does |
|---|---|---|---|---|
| secret → KEK | Tier 1Root secret | Knowledge | kernel | The master secret; PBKDF2-SHA256 (600k) derives the KEK. Built into @noy-db/hub — no on- package. |
| on-password | Tier 2Authenticator | Knowledge | external pkg | A separate frequently-typed password as a keyring slot wrapping the DEK set (LUKS-style); unlock yields kek: null. |
| on-webauthn | Tier 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-pin | Tier 3Quick unlock | Knowledge | 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 factor | Possession | external pkg | RFC-6238 TOTP second factor a policy gate can demand; not an unlock. |
| on-email-otp | 2FAStep-up factor | Possession | external pkg | Short-lived emailed codes via caller transport; not an unlock. |
| on-recovery | RecoveryRecovery (paper) | Possession | external pkg | Printable one-time codes; last-resort unlock, burned after use. |
| on-shamir | RecoveryRecovery (k-of-n) | Possession ×N | external pkg | Splits the KEK into k-of-n shares; combining reconstructs the real KEK. |
| on-magic-link | ViewerDelegated viewer | Possession | external pkg | Single-use link opening a read-only viewer session, no secret; server-secret-bound HKDF. |
| on-threat | OverlayThreat 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
secrettocreateNoydb(); 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 aNoydbSealer: the hub mints a random 256-bit secret at first open and seals it under a key your own infrastructure holds — anat-*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 disablesrotateSecret(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 agetKeyringcallback 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. (secretandgetKeyringare mutually exclusive; bootstrapping a brand-new vault still needs a secret or recovery code once, to enroll the first factor, after which you switch togetKeyring.)
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— anEnrollAuthenticatorOptionsdescribing the slot: its caller-chosenid,method(webauthn/oidc/password), the wrap material produced by the matching@noy-db/on-*package, and method-specificmeta.factors— factor proofs presented to satisfy theenroll-authenticatorgate 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— theidof the slot to remove.factors— factor proofs presented to satisfy theremove-authenticatorgate.
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— theidof the slot to relabel.options— anUpdateAuthenticatorOptionscarrying the replacementmeta.factors— factor proofs presented to satisfy theupdate-authenticatorgate.
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 theUnlockedKeyringand returningEnrollAuthenticatorOptions; typically delegates toenrollWebAuthn()from@noy-db/on-webauthn.factors— factor proofs presented to satisfy theenroll-authenticatorgate.
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— theidof the enrolled slot to unlock with.verify— a callback receiving theKeyringAuthenticatorand returning theUnlockedKeyring.
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— theQuickUnlockStateminted from an already-unlocked keyring.factors— factor proofs presented if therotate-unlockgate 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 storedQuickUnlockStateand returning theUnlockedKeyring.
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.openVaulttreats this as a tier-1 session (most-privileged), sominTierpolicy gates pass — but the keyring it returns still carrieskek: null, so KEK-requiring operations still cannot run.- managed mode (
secretMode: 'managed'+ aNoydbSealer) — 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 theview-user-authgate.
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 theview-user-authgate.