Passwordless OIDC (AWS Cognito)
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:
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 foron-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
OidcEnrollmentrecord — it is opaque metadata (no key material), so any store orlocalStorageworks.
What this gets you
| Capability | Which piece provides it |
|---|---|
| Passwordless sign-in (email OTP / passkey) | AWS Cognito's hosted ceremony |
| Federated identity, no password directory of your own | Cognito user pool + app client |
| Vault unlock from that identity, zero-knowledge | on-oidc split-key + your key-connector |
| Read / write the vault after unlock | the returned tier-2 UnlockedKeyring (kek: null) |
| grant / rotate / enroll-another / open a second vault | Not this session — needs a tier-1 unlock |
| Recover a lost device | a 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.
- 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.
- Add an app client. Its client ID becomes the ID token's
audclaim and yourconfig.clientId. For a public SPA/native client, use a client without a secret and the authorization-code + PKCE flow. - 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 cannotgrant, 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
deviceHalfnever leaves the device; a wiped browser / new device makesunlockOidcfail withOidcDeviceSecretNotFoundError(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 newenrollOidc. Cognito re-proves identity, not your device secret. - Want NO server at all? If running even a tiny key-connector is unwelcome,
on-webauthngives 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.