stable

Passwordless OIDC (AWS Cognito)

Passwordless federated tier-2 unlock with the least infrastructure — AWS Cognito, one tiny key-connector, and the on-oidc split-key client.

Audience: an app developer who wants users to sign in passwordlessly (email one-time-code or a passkey) and open their vault, with the least infrastructure to run yourself. Bundle: core + @noy-db/on-oidc (client) + one key-connector server you host + a managed OIDC provider (AWS Cognito). (★ required)

Verified by: showcases/src/recipe-passwordless-oidc.recipe.test.ts — the client code below is the same code that runs there. CI can't reach real Cognito, so the test signs a Cognito-shaped RS256 ID token locally and verifies it against a local JWKS through an in-memory key-connector; the exact same enrollOidc/unlockOidc calls and the same verifyIdToken() run against real Cognito in production. (★)

Give users a login they never type a vault secret for: they authenticate to AWS Cognito — an emailed one-time code, or a passkey — and that federated identity unlocks their noy-db vault as a tier-2 session. Cognito runs the sign-in ceremony and the account directory so you don't; noy-db stays zero-knowledge because the ID token alone can never decrypt anything. This is the cheapest, least-operations way to get federated passwordless unlock — but be clear with yourself up front: it is not zero-server.

The three pieces (and the honest part)

on-oidc is a Bitwarden-style split-key key-connector design: KEK = serverHalf ⊕ deviceHalf. That means passwordless-federated unlock needs three cooperating pieces, and exactly one of them is a server you must run:

1 · AWS Cognito — the OIDC provider (managed)
Runs the passwordless ceremony (email OTP, or a passkey) and issues an OIDC ID token. noy-db never sees the ceremony — only the resulting token. Proves who, carries no wrap-key.
▼  ID token (RS256, sub + aud)
2 · The key-connector — a tiny server YOU host
PUT/GET /kek-fragment. Verifies the Cognito ID token against the pool's JWKS, then stores/returns the encrypted serverHalf keyed by sub. This is the one piece that is not zero-infra. It holds only serverHalf — useless without the device half.
▼  encrypted serverHalf
3 · The noy-db client — on-oidc
enrollOidc / unlockOidc derive the deviceHalf from a per-device secret (never transmitted) and XOR it with the serverHalf → the KEK → a tier-2 unlock (kek: null).

Neither Cognito nor the key-connector ever holds both halves, so a compromise of either — stolen tokens, or a breached connector — still cannot read the vault. Note that on-oidc itself does not verify the ID token signature — that is the key-connector's job. The client only reads claims for a fast exp pre-flight.

Prerequisites

  • Packages: pnpm add @noy-db/hub @noy-db/on-oidc (+ a store, e.g. @noy-db/to-browser-idb).
  • An AWS Cognito user pool with passwordless sign-in and an app client (below). Cognito's free tier covers a generous monthly active-user allowance, and beyond it the per-MAU cost is low — this is the "cheap managed provider" choice.
  • A key-connector server you host speaking PUT/GET /kek-fragment. It's small (below), but it is a server — if you'd rather run no server at all, jump to Limits & alternative for on-webauthn.
  • A one-time tier-1 bootstrap per user (secret, recovery code, or a custodian invite). The OIDC unlock is enrolled from an already-unlocked keyring — it adds a passwordless way in, it does not create the vault.
  • Somewhere to persist the OidcEnrollment record — it is opaque metadata (no key material), so any store or localStorage works.

What this gets you

CapabilityWhich piece provides it
Passwordless sign-in (email OTP / passkey)AWS Cognito's hosted ceremony
Federated identity, no password directory of your ownCognito user pool + app client
Vault unlock from that identity, zero-knowledgeon-oidc split-key + your key-connector
Read / write the vault after unlockthe returned tier-2 UnlockedKeyring (kek: null)
grant / rotate / enroll-another / open a second vaultNot this session — needs a tier-1 unlock
Recover a lost devicea recovery profile or a fresh tier-1 re-enrollment — see below

Setup

The tier-1 bootstrap is an ordinary createNoydb({...}). Later sessions swap the secret for the OIDC unlock via the getKeyring seam (last block):

import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory' // swap for toBrowserIdb() in a browser

const db = await createNoydb({
  store: toMemory(),
  user: 'alice',
  secret: 'correct horse battery staple', // tier-1 bootstrap, once
})
const vault = await db.openVault('books')

Cognito setup (documented, not CI-tested)

These are AWS console/API steps, so the showcase does not execute them — it stands in a Cognito-shaped token. The setting names below are accurate; consult the Amazon Cognito user-pool docs for the current console click-path, which AWS revises often.

  1. Create a user pool. Choose passwordless sign-in: enable email one-time code (and, optionally, passkey / WebAuthn) as the sign-in method. This is the ceremony noy-db never sees.
  2. Add an app client. Its client ID becomes the ID token's aud claim and your config.clientId. For a public SPA/native client, use a client without a secret and the authorization-code + PKCE flow.
  3. Note the issuer. A pool's OIDC issuer is https://cognito-idp.<region>.amazonaws.com/<userPoolId> and its JWKS is published at ${issuer}/.well-known/jwks.json. Cognito signs ID tokens with RS256. Your key-connector verifies against that JWKS.

That's the whole managed surface — no password reset flows, no email templates, no account tables to own.

The key-connector (the one server you run)

A minimal handler for the two endpoints on-oidc speaks. It verifies the Cognito ID token against the pool's JWKS, then stores/returns the encrypted serverHalf indexed by the verified sub — never trusting a sub from the request body. This mirrors the real, dependency-free connector fixture the showcase runs (showcases/src/_oidc.ts → createInMemoryKeyConnector); the fixture is the same protocol, only in-process.

// key-connector — sketch of the one server piece you host.
// `verifyCognitoIdToken` fetches the pool's JWKS once, caches it, and checks
// signature + iss + aud + exp (RS256). See _oidc.ts::verifyIdToken for the
// exact Web-Crypto verification this stands in for.
import { verifyCognitoIdToken } from './cognito-verify.js'

const JWKS_URI = `https://cognito-idp.${REGION}.amazonaws.com/${POOL_ID}/.well-known/jwks.json`
const store = new Map<string, { encryptedServerHalf: string; iv: string }>()

async function handle(req: Request): Promise<Response> {
  const idToken = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
  let sub: string
  try {
    ;({ sub } = await verifyCognitoIdToken(idToken, { jwksUri: JWKS_URI, issuer: ISSUER, audience: APP_CLIENT_ID }))
  } catch {
    return new Response(JSON.stringify({ error: 'invalid_token' }), { status: 401 })
  }

  if (req.method === 'PUT') {
    const { encryptedServerHalf, iv } = await req.json()
    store.set(sub, { encryptedServerHalf, iv }) // sub from the VERIFIED token, never the body
    return new Response(JSON.stringify({ ok: true }))
  }
  // GET
  const frag = store.get(sub)
  return frag
    ? new Response(JSON.stringify(frag))
    : new Response(JSON.stringify({ error: 'not_found' }), { status: 404 })
}

The connector holds only serverHalf, which is worthless without the device-bound deviceHalf — that is why a connector breach does not read the vault.

The noy-db client

Build the Cognito config as a plain OidcProviderConfig. knownProviders ships shortcuts for google / apple / line only; every other issuer — Cognito included — is this same public object shape, so nothing here is a workaround:

import { enrollOidc, unlockOidc, type OidcProviderConfig } from '@noy-db/on-oidc'

const config: OidcProviderConfig = {
  name: 'AWS Cognito',
  issuer: `https://cognito-idp.${REGION}.amazonaws.com/${POOL_ID}`,
  clientId: APP_CLIENT_ID, // the app-client id == the ID token's `aud`
  scopes: ['openid', 'email'],
  keyConnectorUrl: 'https://key-connector.example.com',
}

Enroll once, from the tier-1-unlocked keyring, with a fresh Cognito ID token. This derives the device secret, XOR-splits the keyring payload, and PUTs the encrypted serverHalf to your connector:

// `idToken` came from the completed Cognito passwordless sign-in.
const enrollment = await enrollOidc(await db.team.getKeyring('books'), 'books', config, idToken)
// Persist `enrollment` (it's opaque metadata) so later sessions can find the `sub`.

Unlock on every later session with a new Cognito login — no secret typed. Feed the returned keyring straight into createNoydb via getKeyring, the cold-start seam every on-* method satisfies:

const unlocked = await unlockOidc(enrollment, config, freshIdToken)

const db = await createNoydb({
  store: toMemory(), // the same store that holds the vault
  user: 'alice',
  getKeyring: async () => unlocked, // no `secret` — passwordless from here on
})
const books = await db.openVault('books')
// books.collection(...).get(...) / .put(...) all work — this is a real session.

Verify

The showcase proves the property, not just the happy path: after unlockOidc, unlocked.kek is null — the tier-2 signature — yet the session reads a record written under tier 1 and writes a new one. It also asserts the connector received only opaque ciphertext, and that a wrong-issuer, wrong-aud, expired, or wrong-sub token is rejected:

const unlocked = await unlockOidc(enrollment, config, unlockToken)
expect(unlocked.kek).toBeNull()          // tier-2: no KEK, ever
expect(await invoices.get('inv-1')).toBeTruthy() // …but reads/writes work

Limits & alternative

  • It's a tier-2 unlock, with tier-2 limits. The OIDC session yields DEKs with kek: null: it can read and write, but cannot grant, rotate the secret, enroll another authenticator, or open a second vault — those need a real tier-1 unlock. See Authentication → Can a method serve a different tier?.
  • Lose the device half and Cognito alone can't get you back in. The deviceHalf never leaves the device; a wiped browser / new device makes unlockOidc fail with OidcDeviceSecretNotFoundError (or a 404 from the connector). Recovery is a fresh tier-1 re-enrollment — a paper recovery code, Shamir share, or a custodian invite — then a new enrollOidc. Cognito re-proves identity, not your device secret.
  • Want NO server at all? If running even a tiny key-connector is unwelcome, on-webauthn gives a similar login-with-your-device UX using a platform passkey (Touch ID / Windows Hello) with no key-connector and no OIDC provider — the wrapping key is derived from the authenticator's PRF output on-device. Choose OIDC when you specifically want federated identity (a shared corporate/social login) or central account management; choose WebAuthn when you want the least infrastructure.

See also

  • on-oidc — the split-key model and the shipped provider configs, in depth.
  • on-webauthn — the no-server passwordless alternative.
  • Unlock ceremonies — how OIDC stacks with secret, passkey, session policy, and recovery.
  • session-tiers — the three-tier ladder and what each tier may do.