Skip to main content

Changelog

WIP

Release history for @gentleduck/auth. Newest releases at the top.

This page mirrors the package changelog shipped inside @gentleduck/auth at packages/duck-auth/CHANGELOG.md. That file is the source of truth; this page is kept in sync with it by hand. Entries run newest first. Names in each entry are the names that were current in that release, so an old entry can name something that has since been renamed or removed.

5.13.0

Minor Changes

  • 7228f81: Expose updatedAt on the session, credential and api-key surfaces.

    Every SQL dialect already carried an updated_at column, maintained by drizzle's $onUpdate, and every

dialect then projected it back out of the row before a caller could see it. Identities.Me exposed its own; Sessions.Me and Credential.Me did not, so there was no way to ask when a session or a credential was last written.

  • Sessions.Me.updatedAt and Credential.Me.updatedAt are now part of the row, and so is Credential.Public, which is Me minus the secret.
  • ApiKeys.ApiKey.updatedAt comes through the projection over the backing credential row.
  • The OpenAPI Session schema declares it, so generated clients type it.
  • The memory and redis stores stamp it on every write, matching what $onUpdate does in SQL. A caller cannot set it through a patch: it tracks the write, and is not backdatable.
  • A redis session written before this release has no updatedAt field. It reads back as its createdAt rather than being refused, so an upgrade does not sign existing sessions out.

The store-compliance suite now asserts, on all six backends, both that the field is present and typed and that every write actually moves it — an exposed timestamp that never changes would be worse than no timestamp at all.

Implementors of a custom Sessions.Store or Credential.Store must return updatedAt on the rows they answer, and Sessions.CreateInput now carries it; the engine supplies it at create.

Stop sending the session's CSRF hash to the browser.

GET /session answered JSON.stringify(resolved.session), and the row carries csrfHash. All seven HTTP adapters did it, so the per-session hash reached every client that asked who it was, for a field no client has a use for: the browser echoes the plaintext from its __Host- cookie, and the comparison is server-side. Identities.exportAll had always stripped the field, so the package's own intent was clear and the route was the one place contradicting it.

  • The handlers strip it inline, so the wire now carries the row minus that one field.
  • Sessions.Public names that shape, and VanillaClient.SessionResult.session plus reviveSession are typed to it rather than to Sessions.Me. A caller reading session.csrfHash off a client result now fails to compile, which is the point.
  • Two guards, both mutation-checked: a static-analysis test that scans every server/*/index.ts from the directory rather than a list, so a new adapter is covered the day it lands, and a live assertion on the hono route's response body.

Answer the facts the API already held but did not hand back.

  • The OpenAPI Session schema declared 12 of the row's fields. ip, userAgent, fingerprint and actingAs are on the wire and non-secret, so a generated client was typed narrower than its own responses. They are declared now; csrfHash stays out, deliberately.
  • ApiKeys.create accepts a tenantId and exchange reports one, but ApiKeys.ApiKey dropped it, so a key could be scoped to a tenant and never read back. It is now on the record, absent when unscoped.

Still deliberately hidden: Credential.secret, Sessions.Me.csrfHash outside the server, and mysql's generated passwordKey/emailNorm/usernameNorm, which are index carriers rather than fields.

Mark auth responses uncacheable.

Nothing in the package set Cache-Control — not on a route, not in a shared helper, and not as advice in the docs. GET /session is the sharp end: one URL for every caller, a body that differs by cookie, and no Vary. A CDN or proxy caching it hands one caller another's identity and session. The same applied to the magic-link and OAuth callback GETs, whose URLs carry a one-time token and an authorization code.

Every HTTP adapter now sets cache-control: no-store on the responses it builds, including error and redirect paths. grpc is exempt: it is not HTTP. A static-analysis test enforces it per adapter, derived from the directory rather than a list, and the hono session route asserts the header on a live response.

Close the timing oracle on the magic-link request.

requestPasswordReset is careful about this: both branches mint and hash a token and make the same store calls in the same order, because "response time answers what the response body refuses to". magicLink.begin implements the same contract and did the opposite — an unknown address returned before the token mint and before the credential insert, so it was measurably the faster one, and the endpoint told a caller whether an address had an account. Its comment claimed the branches matched; only the channel dispatch did.

  • The unknown branch now mints and hashes a token like the known one, and answers the write with a read of the same table under the same tenant scope. A decoy write is not possible: auth_credentials.identity_id is a foreign key, which is the reason the reset flow reads there too.
  • autoCreateIdentity and autoCreateProfile behave as before; only the early returns are gone.

The existing magic-link-timing-defense.test.ts passed throughout, both before and after. It compared wall-clock across the two branches with a 50 ms tolerance, against the memory adapter, where the skipped insert costs microseconds — it could only ever prove that neither branch blocks on the channel. It now also counts the store round trips each branch makes and requires them to line up, which is the assertion that fails on the old code: the unknown branch made none where the known one made two.

CSRF-guard the passkey routes, and bound passkey.begin.

mountHono mounts passkey/begin and passkey/complete as POSTs. Neither called csrfGuard, while /signin, /providers/:id/begin and all five MFA routes in the same file do — and passkey/complete is a sign-in, the same thing /signin is. There was no note anywhere saying the omission was meant. Both are guarded now, and a new test walks the routes mountHono actually registers and requires every POST among them to refuse a cross-site request, so a route mounted without the guard fails the day it lands rather than whenever someone next reads the file.

passkey.begin also consumed no rate limit, where passwords, magic-link, api-key and saml each bound their entry point and flows.signIn has no central limiter to fall back on. It is keyed on the canonical address when one is supplied, and on the session id otherwise. limiterKeyPrefix joins the other passkey options, defaulting to passkey:begin:.

Refuse a revoked passkey on the branch that mints the session.

passkey.complete read cred.revokedAt for its truthiness, where the offer path twelve lines above uses isRevoked and carries a comment saying why: a revokedAt of 0 is falsy, so a store keeping timestamps as epoch ints would answer a revoked row and this read it as live. The same bug was found and fixed for TOTP and for beginWebauthnMfaVerify, both of which now note it in place; the accept path was missed, and it is the more consequential of the two, since the offer path only lists a credential while this one returns startSession. No adapter shipped here can produce it — Credential.Me['revokedAt'] is Date | null and new Date(0) is truthy — so this closes it for the pluggable store contract and for consistency, not against a live exploit. A test drives the assertion through a store answering 0 and asserts the mismatch; with the old check it resolved to startSession.

Make the OpenAPI spec describe the routes the adapters actually mount.

buildOpenApiSpec declared a route layout the framework adapters have never mounted. Six of its nine paths did not exist: /password/sign-in against a mounted /signin, /sign-out against /signout, /passkey/verify against /passkey/complete, and /oauth/{provider}/start plus /magic-link/request against the single /providers/{id}/begin that drives every two-step provider. The five mounted MFA routes were absent altogether. The spec's own info.description calls these "routes mounted by @gentleduck/auth framework adapters", so a client generated from it 404s on sign-in and sign-out. The existing test pinned the wrong paths, which is why it stayed quiet.

The path table now follows mountHono one for one, GET /session answers a SessionResult envelope rather than a bare Session (both members are null when nothing resolves, and that is a 200), and the gate names match the adapters' skip flags — so OpenApi.Cfg['providers'] takes 'magic-link' | 'oauth' | 'passkey' | 'totp'. 'password' is gone, because /signin is mounted unconditionally and takes any registered provider by id. Anyone generating a client from this spec gets different paths, which is the point: the old ones did not resolve.

A new openapi-route-parity test mounts the router against a recorder and diffs it against the spec, at default config and once per skip group, so a route added, renamed or gated on one side fails there instead of in someone's generated client.

The four routes also advertised an Idempotency-Key header. auth.idempotency is real and works, but it is a facet a host wraps its own routes in — no adapter reads the header, so the spec was promising that a retry replays the cached response when every retry executes again, sending a second magic-link mail or burning another rate-limit token. The parameter is dropped and a test now asserts the spec does not mention it. Wiring the header into the adapters is a feature, not a fix, and is left alone.

Describe the sign-in response the routes actually send, and stop SignInResult describing one nothing does.

Correcting the paths left the schemas untouched, and they were wrong in the same direction. SignInResult declared a 200 of either { session } or { mfaRequired: true, methods }. Neither is ever sent: mfaRequired appears nowhere in the package outside this file, because a second factor is not a 200 with a flag — flows.signIn throws AUTH_STEP_UP_REQUIRED, which the adapters map to a 401 carrying detail.challenge. And a successful sign-in sends no body at all; the session rides in the Set-Cookie the transport issues, which is why the client re-reads it with GET /session. The schema is gone, the four sign-in completing routes declare an empty 200 with a documented Set-Cookie and a 401 that names the step-up code.

This one is pinned to the wire rather than to another list: the new test drives a real password sign-in through the mounted handler and asserts the status, the empty body and the cookie, then asserts the spec declares no content for that 200. It failed on exactly that last assertion before the fix.

Say that the client signUp route is yours to mount.

signUp defaults to POST /signup and its docs named that path the way signIn names /signin — but no adapter mounts a registration route, so the default 404s while the neighbouring default works. The method is still worth having, since it carries the CSRF header, the same-origin credentials and the envelope that a bare fetch would not; the docs now say the route is the host's and the default is a placeholder. A test pins the three client defaults the library does back, and pins /signup as absent so mounting one later cannot quietly contradict SignUpOptions.

Stop the client reporting a signout the server refused.

client.signOut() discarded the envelope call had just built and answered { ok: true } whatever happened — a 500, a CSRF refusal, an unreachable host. Clearing the local session regardless is right: the caller asked to sign out, and a cached session outliving the request is worse than none. Reporting success is not, because the session is still live on the server and the cookie is still in the browser, which is the one thing signout exists to rule out. It now clears local state exactly as before and returns what the server said, the way signIn already surfaces its failures. Callers that assumed ok is always true on this method will now see false, which is the point. Two tests cover the refused and the unreachable case; neither path had one, which is why this stood.

The as cast there was holding up a second untruth: the method declared Envelope<Record<string, never>, string> while the route answers an empty body, so data is null at runtime and has always been. signOut is typed Envelope<unknown, string> now, matching signUp, and the cast is gone. The same declaration appeared in the react, vue, solid and svelte bindings, all four of which delegate to the vanilla client and so carried the same lie; all four are corrected.

Cover the react client, which nothing did.

./client/react is a published entry point with no tests at all, while the vue, solid and svelte bindings each have some. React hooks need a renderer and react-dom is not a dependency here, so the new test pins what is checkable without one: that the module imports against the installed React and exports its whole surface, so a dropped export or a broken barrel fails here rather than in someone's app.

Worth a decision, not changed here: react exposes useSignUp and useBeginProvider, and the vue and solid bindings expose neither. Nothing is unreachable — every binding exposes the client itself, so client.signUp(...) and client.beginProvider(...) are always in reach — but the hook-level surface is uneven between siblings.

Repair a rename sweep that replaced _ with / inside names that were never paths.

An earlier refactor rewrote import specifiers by replacing duck-auth with auth/, and the replace ran over every string that shared the prefix. The import paths were cleaned up afterwards; the strings that merely looked like them were not, and they are not the kind of thing a type-checker reads:

  • duck-auth init scaffolded process.env.DUCK_AUTH/BASE_URL, which is not an environment variable name — it parses as a division, so the generated auth.ts failed to compile with TS2304: Cannot find name 'BASE_URL'. The same three names were wrong in the generated .env.duck-auth, and in what keys generate and keys rotate tell you to paste where.
  • duck-auth migrate <dialect> defaulted to --prefix=AUTH/ and emitted CREATE TABLE IF NOT EXISTS AUTH/identities, which no dialect will parse. The prefix is auth_ again, which is also what the drizzle schemas name their tables.
  • duck-auth doctor looked for src/AUTH/auth.ts, and init writes src/auth/auth.ts by default, so init followed by doctor could never find what init had just written.
  • AUTH_DEFAULT_EN_MESSAGES keyed ten of its twelve error-code entries as 'AUTH/LOCKED' and the like. The resolver falls back to the id it was handed, so every one of those lookups missed silently and rendered the raw code to an end user. The one key that survived is AUTH_INVALID_CREDENTIALS — the formatter quotes only keys that are not legal identifiers, so the quoting itself marks the damage.
  • DEFAULT_MAGIC_LINK_CONFIG.callbackPath was /AUTH/magic-link/callback, and the provider builds the emailed URL from it, so every magic link sent at default config pointed at an upper-cased path.
  • The vue, solid, react and vanilla clients named @gentleduck/AUTH/client/* in the errors they throw, and vue's injection key was Symbol.for('@gentleduck/AUTH/client/vue').

Seventy more occurrences were test titles and failure messages naming codes like AUTH/STALE_WRITE, which means grepping for an error code did not find the test that covers it.

Fix the scaffold's imports, which named symbols that no longer exist.

Separately from the rename, both init flavors still named the pre-a209418d symbols — AuthMemoryAdapter, AuthCookieTransport, AuthScryptHasher, AuthJwtTransport, AuthArgon2idHasher — after that commit stripped the Auth prefixes. They also imported from @gentleduck/auth/providers/password, which the exports map does not have (it is passwords), named RedisSessionStore and RedisIdempotencyStore, which have never existed in any form, took RedisLimiter from adapters/redis rather than limiters/redis, and passed env: 'production' to new AuthEngine, which takes no such option. Both templates now type-check: tsc on the generated output reports nothing but the unresolved imports that --noResolve guarantees.

README.md's examples were pinned against the exports map in an earlier round; that guard now runs over the init output too, which is the same kind of string and rotted the same way. The CLI's own tests had asserted toContain('AuthMemoryAdapter') and toContain('RedisSessionStore') — an assertion that a name is present can never notice that the name resolves to nothing.

Create the scaffolded env file owner-only.

init wrote .env.duck-auth at the umask default, typically 0644, and the file exists to hold DUCK_AUTH_HS256_SECRET. It is created 0600 now; mode is masked by the umask, so this can only narrow. It also reported scaffolded <path> for an existing env file it had deliberately left alone, and now says kept existing.

Worth a decision, not changed here: DEFAULT_MAGIC_LINK_CONFIG.callbackPath is /auth/magic-link/callback once the case is repaired, but the only magic-link route any adapter mounts is mountHono's /auth/magic-link/verify. The default link lands on a route that exists nowhere. Changing a public default is a behaviour change, so the case fix is all that is applied.

Read the GitHub account's verified address, so link-if-verified can fire for GitHub at all.

The GitHub provider requested the user:email scope and then never called /user/emails. It took the address from /user, which carries only the public profile email — unset by default, and carrying no verification claim either way — and it never set emailVerified. resolveFederationConflict requires profile.emailVerified === true to link, so a host that configured onFederationConflict: 'link-if-verified' got a policy that rejected every GitHub sign-in it was ever asked to arbitrate. It failed closed, which is why nothing caught it: a declared feature wired to nothing.

fetchProfile now reads /user/emails and takes the row that is both primary and verified, which is the only thing that justifies setting emailVerified: true. Verification is read from GitHub, never assumed. This is best effort by design: a token whose scopes were narrowed, a rate limit, or an account with no verified address all fall back to the /user email carried without a verification claim, which is the posture the Microsoft provider already takes. Sign-in is never failed over it.

New on OAuthClient: authedJson(url, accessToken, providerId), an authenticated JSON GET under the same 64KB body cap as userinfo, for any provider whose profile needs a second call. It refuses a non-http(s) url before reaching fetch, because a host's own fetchProfile receives the client and can call it.

The provider had no test file at all — it was the lowest-covered module in the package at 16.66% of statements. It now has nine, covering both endpoints, both fallbacks, and both federation outcomes.

Worth a decision, not changed here: Sign in with Apple cannot complete a flow, at three layers. Apple requires response_mode=form_post whenever any scope is requested, and begin() emits scope=name+email with no response_mode, so Apple answers invalid_request. The only oauth callback any adapter mounts is a GET reading query params, so Apple's POST would 405. And the __Host-duck-oauth binding cookie is SameSite=Lax, which a browser withholds on a cross-site POST, so the state binding check would fail with the cookie absent. The last of those is the decision: Apple needs SameSite=None on that cookie, and Lax is load-bearing CSRF defence for the other five providers. Full write-up in docs/superpowers/notes/2026-09-19-apple-cannot-complete-a-flow.md.

Related and also unchanged: OAuthClient.buildAuthorizeUrl accepts extraParams and no caller anywhere passes it — it is the hook Apple's response_mode needs. The apple provider requests the name scope, which Apple only ever delivers in the form_post body it cannot currently receive. The microsoft provider requests User.Read while reading only the OIDC userinfo endpoint that openid/profile/email cover; that one needs checking against a live tenant before anything is removed, since it may be what forces the access token's audience to Graph.

Declare the five optional integrations that are imported at runtime and were in no dependency field.

@aws-sdk/client-ses, @opentelemetry/api, resend, twilio and web-push are each loaded with await import('name' as string). The as string is deliberate — it stops the bundler resolving them, so an app that never sends SMS never pays for twilio. It also stopped anything noticing they were declared nowhere: no version range was expressed for any of them, and each one's own failure message told the user to install a "peerDep" that did not exist. The sibling tell is exact: @aws-sdk/client-kms is loaded the same way and is declared >=3 <4 optional; @aws-sdk/client-ses is loaded the same way and was not.

All five are now peerDependencies with peerDependenciesMeta.optional, matching the ten that were already there. @aws-sdk/client-ses takes >=3 <4 from its sibling and @opentelemetry/api takes >=1 <2; the ranges for resend, twilio and web-push are lower bounds read off the single symbol each call site uses, so narrow them if you mean to support a specific major. Nothing is forced on a consumer: an optional peer that is absent still fails the same way, with the same message.

src/__tests__/lazy-peer-deps.test.ts now pins it from both ends — every dynamically imported bare specifier must be a declared optional peer, and every package name an error message tells the user to install must be a real key of peerDependencies. The two halves found the same five independently.

Stop mountHono documenting a CORS middleware it does not mount.

mountHono's JSDoc read "opts.cors mounts a scoped CORS middleware". Nothing reads opts.cors, and MountHono.App declares only get and post, so no middleware can be mounted through that type at all — the option is not merely unimplemented, it is unimplementable as typed. A host setting cors: { origins: [...] } got silence. It fails closed, so the consequence is a broken cross-origin integration rather than an open one. Both comments now say it is inert; the option itself should be implemented or removed, which is a decision, so it is left in place.

Related comments that named a dependency field the package does not have: hono and @grpc/grpc-js were both described as peerDeps and are in no dependency field, because both adapters are structurally typed and neither package is ever imported. The gRPC one also called it "lazily loaded"; there is no import.

Pin what opts.skip actually removes.

mountHono's skip had no test. It is also not the clean partition its names suggest: 'totp' gates every MFA route, backup-code regeneration included, so a host that offers backup codes but not TOTP cannot ask for that. Documented on SkipGroup and pinned in hono-skip-groups.test.ts, which asserts each group removes exactly its own routes and that sign-in, sign-out, session and provider-begin survive every combination. Worth a decision, not changed here: whether that grouping should split.

Also added: src/server/__tests__/adapter-options-wired.test.ts, which fails when a field is added to any adapter's *Options type and read by nothing — the shape cors had.

Make strict({ env: 'production' }) refuse a forgeable signing secret.

The production gate checked the limiter, the stores, the idempotency store, cookie secure, the baseUrl scheme, that a provider exists and that someone listens for lockout — seven checks, none of which looked at the secrets those sessions are signed with. A deployment with a one-character HS256 signing key and a one-character oauth stateSigningSecret passed every one of them. strict() is the call a host makes to be told what is wrong before it serves traffic, so fixing the six things it did report and shipping was the documented path to a green gate on a deployment whose session tokens anyone could mint.

RFC 7518 section 3.2 requires an HMAC key at least as long as the hash it feeds, so 32 bytes is the floor for HS256. AuthJwtTransport and the oauth provider each compute one boolean at construction — __weakSigningKey, __weakStateSecret — and strict() reads them by brand, the same way it already reads __isNoopLimiter and __isMemoryStore. The brands are booleans the holder derived; neither carries the secret, and neither is on the public type. A transport or provider that publishes no brand is not checked, there being no way to read a foreign implementation's key. Providers are read through engine.providers.list() rather than cfg.providers, so one supplied as a factory is checked too.

Empty is separated from short, because they are different failures. An absent stateSigningSecret is not an unsigned state — createHmac accepts an empty key and produces a perfectly valid MAC with it, so the state is signed with a key every attacker also has. That is refused at construction, in every environment, rather than recorded for a strict() call the host may never make. A short-but-present secret is a production policy question, so it is reported by strict() and only in production, matching every other check there.

The CLI's --production scaffold has claimed since it was written that this call "refuses to start on a weak secret, an insecure cookie, or a missing limiter". Two of those three were true. The comment is now accurate as written, and .env.duck-auth already tells operators to use 32 bytes.

Six tests in src/core/__tests__/strict.test.ts cover it, including that key length is silent outside production. Each check was mutation-proven in its own run, the floor value included.

Refuse a batch unlink that would lock an account out, and emit the event that says one happened.

identities.unlink refuses to drop the last way into an account: the remaining provider links plus the live credentials must not come to zero. identities.unlinkMany called the store directly and applied no such check, so the batch path — the one a bulk admin action takes — could leave an identity with no provider and no credential, reachable by nobody. flows.unlinkProvider refuses the same thing twice, before and after the write, with a rollback; the facet is simply the weaker of the two implementations.

identity.unlinked was declared in the event catalogue as "the mirror of identity.linked", with a SECURITY note calling it "the half an account takeover performs, dropping the real owner's login so only the attacker's route is left". flows.unlinkProvider emitted it. The facet did not, on either method, so a host subscribing to it for alerting saw nothing when the write came through auth.identities.

Both are fixed by delegation rather than by a second copy of the check: unlinkMany now calls unlink per row and linkMany calls link, both through the refusable() wrapper that already turns a refusal into a skipped row rather than an aborted batch. A duplicated guard is what let these drift in the first place. linkMany inherits the duplicate-providerId refusal the same way. allowedLockout is always false from this facet, which is accurate: the override is flows.unlinkProvider's, and this path has none — a host that means to strand an account still has that route.

A batch unlink that previously stranded an account now skips that row and returns without it. The returned array is what it always was: the rows actually written.

Record who ran a batch erasure, and stop a bulk import swallowing driver failures.

identities.erase takes { reason, operatorId } and binds the actor so the store stamps who performed the erasure. identities.eraseMany took a bare array of ids and bound nothing, so the irreversible operation was attributable on the single path and anonymous on the batch. It now takes the same envelope and binds the actor once around the one adapter call; omitting operatorId still leaves an outer request-scoped actor alone, because withActor(undefined) is a fence that clears the scope rather than a no-op. eraseMany(ids) is now eraseMany(ids, { reason }) — a required argument, matching erase. The Identities.Store contract is unchanged: adapters carry attribution through the actor scope.

bulkCreate caught every error from a row and counted it as failed. This file has refusable() for exactly this distinction, and its comment says why: Postgres leaves a transaction aborted once a statement has failed, so swallowing one makes a later COMMIT a silent ROLLBACK. A bulk import that lost its connection returned { created: 0, skipped: 0, failed: N } and threw nothing. A refusal this layer decided is still a failed row; a driver failure now propagates.

bulkCreate in merge mode also linked through the store rather than through link, so a provider folded into an existing account emitted no identity.linked. It goes through link now, which is also where the duplicate-providerId refusal lives, so the inline re-check is gone.

Worth a decision, not changed here: bulkCreate in replace mode erases the account it is replacing through the store, with no reason and no operatorId, where erase and eraseMany both require one. An ambient actor still flows through, so attribution is possible; it is never explicit.

Spending an MFA backup code now reaches the event bus. recovery.mfa.escalated was declared on Events.EventMap, listed in AUDITED_EVENTS so the audit envelope would be stamped onto it, and allowed through the webhook event filter — and nothing ever emitted it. flows.completeStepUp accepts method: 'backup-code', rotates the session to aal: 2 and records backup-code on its factor list, and that session row was the only trace: no webhook fired and no audit subscriber could see that an account's second factor had been satisfied without the factor. MfaImpl.verifyBackupCode emits it now, after the compare-and-set claim and the revoke, so a subscriber reading the row it names finds the code already burnt and revoked, and a losing racer emits nothing.

Breaking, at the type level only: the payload's ticketId is now credentialId, carrying the backup-code row that was spent. duck-auth has no ticket concept — ticketId appeared nowhere but its own declaration — and credentialId is what mfa.ts already calls this identifier for webauthn enrolment and in the suspicious payload. Nothing emitted or subscribed to the event, so any handler written against the old name was unreachable code.

Worth a decision, not changed here: BackupCodesFacet.verify, the second backup-code implementation, still emits nothing. It is wired by the host rather than mounted on AuthEngine and holds no event bus, so giving it one changes a published constructor signature — a required parameter breaks direct constructors, an optional one puts the silent gap back as a configuration footgun.

The client_credentials grant is rate-limited, and reachable. M2MImpl.exchange compares a caller-supplied client_secret against stored material through the same ApiKeysFacet.verify that ApiKeyProvider.complete uses. That provider guards the call twice — a type-and-length check so a non-string cannot throw a TypeError past the quota, then limiter.consume — and exchange did neither, leaving an unbounded online guessing oracle for service-account secrets.

M2MImpl now takes a Limiter.Me as its fourth constructor argument, ahead of the optional config, and m2m() takes one in the same position. The quota is keyed on clientId, not on the presented secret: a brute force sends a different secret every attempt, so keying on the secret hands every guess a fresh budget and bounds nothing. clientId and clientSecret are now required to be strings of at most 512 characters, checked above the limiter so an unbounded id cannot become an unbounded limiter key. This is a breaking constructor change for anyone constructing M2MImpl directly — which, until this release, was nobody outside the package, because of the next paragraph.

@gentleduck/auth/core exported type { M2m } and no runtime symbol, so the grant could not be reached: a host could name M2m.ExchangeInput and had no way to obtain anything that accepts one, while the class docstring told them to mount a /oauth/token route calling exchange(). M2MImpl, m2m and DEFAULT_M2M_CONFIG are now exported from ./core. The root export surface is unchanged.

An impersonation can name the authorization that allowed it. identity.impersonated has always declared an iamDecisionId, documented as "the one entry an audit log cannot afford to be missing", and the field appeared exactly once in the package — in that declaration. No option carried it, the only emitter never set it, nothing read it. Flows.ImpersonateOptions now takes iamDecisionId, validated to 1–256 characters like the reason beside it, and it reaches the event. An impersonation that names no decision publishes no key at all rather than an explicit undefined.

Not changed here, and worth a decision: flows.impersonate still accepts an arbitrary authorize callback, so the declared AUTH_IMPERSONATE_REQUIRES_IAM is thrown by nothing and an impersonation with no IAM decision behind it remains possible. Requiring one is a breaking policy change.

Also reported, not changed: OAuth.StatePayload.nonce is documented "one-time use" and nothing enforces it. The nonce is minted, signed, round-tripped and shape-checked, but never recorded or compared, and AUTH_OAUTH_NONCE_REPLAY is declared for the violation and raised by nothing. DPoPVerifier already enforces the same property through a nonce store, and both a memory and a Redis store ship, so the machinery exists — but the oauth provider holds no store, and adding an optional one would put the silence back as a configuration footgun.

FakeRedis now expires keys of every type, not only strings. The in-process redis used by every unit test of the redis-backed stores — and exported publicly on the ./test subpath — held its TTLs on string entries, so EXPIRE on a set or a sorted set answered 0 and set nothing. RedisSessionImpl keeps its per-identity session index in a set and bounds it with one expire call; against this fake that call did nothing, so the index was unbounded in every test that has ever run. TTLs now live in one map keyed by the key, a bare SET drops a prior TTL as real Redis does without KEEPTTL, DEL clears it, INCRBY keeps it so a rate-limit window survives its own increments, SCAN walks sorted sets too, and every set and sorted-set read consults expiry first.

Passkey registration can request direct attestation, and strict() now checks that it does. Passkey.Options gains attestationType ('none' by default); the registration call hardcoded 'none', so the fips compliance preset's webauthnAttestationDirect requirement was unreachable on that path while mfa's WebAuthn enrollment honoured the same option. strict() now reads the setting off the registered passkey provider instead of taking the operator's word, and observed evidence takes precedence over a supplied attestation — a deployment can no longer claim limiterRequired while holding the Noop limiter.

The sqlite adapter waits for pragma foreign_keys = on before answering. It was issued fire-and-forget in the constructor. A raw driver sets it synchronously through exec first, but the adapter also accepts a drizzle handle you already have, and for an async one the first queries ran with foreign keys still off — the state that leaves dangling provider links behind an erased identity. The pragma is now awaited at the boundary every facet calls through, and a pragma that fails surfaces as an adapter error instead of an unhandled rejection on an unenforced connection.

RedisEvents reports a fan-out it could not perform. A rejected publish was answered with () => 0, so emit resolved as success while no other node in the fleet received the event and nothing recorded it. The failure is now logged with the event name, and emit still resolves — local handlers have already run, so a redis blip does not become a failed sign-in.

The session stores with no schema now enforce the constraints the SQL ones do. assertSessionAllowed carried three of the seven CHECK constraints every dialect declares, so memory, redis and valkey accepted a session id that was not a 64-character hash, a deadline preceding the row's own creation, a rotation preceding it, and an absolute cap below the sliding expiry. The first of those let a caller use the bearer token itself as the primary key; the last left the session with no absolute cap at all. The shared store compliance suite defaulted session ids to a raw label that every SQL adapter overrode away, so it had been asserting a different contract on either side of the divide it exists to erase — it now hashes by default.

strict({ env: 'production' }) now refuses the in-process limiter. It rejected the in-process stores by brand but checked only AuthNoopLimiter for the limiter, so a deployment supplying AuthMemoryLimiter — or relying on the engine's fallback to it — passed the production gate holding a class whose own docstring reads "Dev/test only". Across a fleet that grants every brute-force budget once per node, and a restart returns all of them. It also reached the compliance evidence, which positively reported limiterRequired satisfied for it and, since engine evidence takes precedence, overrode an operator attesting otherwise. Breaking for any production deployment currently passing AuthMemoryLimiter or omitting a limiter: strict() will now refuse to boot. Wire AuthRedisLimiter, or your own. The in-process events bus, passkey challenge store and DPoP nonce store remain unchecked and are reported in the audit log rather than changed here.

An expired session is refused even when the cleanup write fails. getBySid, touch and resolveBySid delete the dead row before throwing AUTH_SESSION_REVOKED, and that delete was awaited unguarded — so a store that refused the write returned AUTH_ADAPTER_FAILED in the refusal's place. That code is not in ABSENT, so the resolveSession(...).orNull() every server adapter calls rethrew it and a merely-expired session came back as a 500 instead of a 401. The cleanup can no longer change an answer that was decided before it ran.

A signup flow token now expires. beginSignUp writes two deadlines — the credential's 24-hour absolute cap and flow.expiresAt, the sliding thirty-minute window every advanceSignUp pushes forward — and only the read path checked the first, while nothing anywhere compared the second against the clock. advanceSignUp and completeSignUp therefore accepted a flow token at any age, and completeSignUp mints a session, marks the address verified and merges the staged profile. Nothing swept the row either, the credential store contract having no gc, so an abandoned signup's token stayed a valid account takeover indefinitely. The three entry points now share one liveness check, and each still answers with its own error code.

The in-process identity store now enforces the identity constraints every dialect declares. Both logins must be a non-empty string and fit their column (320 / 191), and neither half of a provider link may be blank — the five chk_auth_identities_* and chk_auth_identity_providers_* constraints pg, mysql and sqlite all carry, and which the memory store and the identities facet checked nowhere. A blank, missing, non-string or over-long username/email was written in dev and refused on the first production write, and such a row is not findable by the address it was meant to carry, since the uniqueness pass reads a blank login as absent. Breaking for a host whose profileToIdentityProfile can yield a blank address — the shape most of them are written in — which now fails at the store rather than silently in dev only.

A session that timed out now says so. AUTH_SESSION_EXPIRED — declared with an { expiredAt } meta and translated in i18n — was raised by nothing; every deadline threw AUTH_SESSION_REVOKED, which is also what a store answers for a row that is absent, corrupt or another tenant's, and whose only detail is a free-text reason. getBySid, touch, resolveBySid and the JWT transport's exp check now raise the expiry code and name the instant, so a caller can tell "sign in again" from "this session was revoked". The code joins the absent set in the same change, so resolveSession(...).orNull() still reads a timed-out session as absence rather than an error. Breaking for a caller matching on AUTH_SESSION_REVOKED to detect a timeout — match AUTH_SESSION_EXPIRED, or both. The store layer is unchanged. A further ten declared error codes are raised by nothing at all; they are listed in the audit log rather than changed here.

A stateless transport's verdict now survives the store fallthrough. resolveSession verifies the token with the transport and, where that does not vouch for it, falls back to looking the token up as a session id — which is what lets one engine accept both a cookie sid and a bearer JWT. That fallback also ran for a token the transport had authenticated and then refused, and since the store is keyed by sid hash it can hold no row for a minted token, so its no session for that sid overwrote the real answer: an expired JWT reached the caller as AUTH_SESSION_REVOKED, never as AUTH_SESSION_EXPIRED, through resolveSession and through CompositeTransport alike. AUTH_SESSION_EXPIRED is the one refusal a transport reaches only about a token whose signature verified — every "not a token of mine" rejection is AUTH_SESSION_REVOKED — so it is now kept and answered, and every other refusal still falls through exactly as before. Breaking for a caller matching AUTH_SESSION_REVOKED to detect an expired bearer token, which is the same move as the change above and now reaches the path server adapters actually take.

strict() now rejects the two dev-only implementations NODE_ENV was the only thing guarding. MemoryIdempotency and AuthNullCaptchaVerifier each refuse to construct under NODE_ENV=production, but strict({ env: 'production' }) is a separate declaration and most runtimes leave NODE_ENV unset, so on those deployments nothing refused them. strict() covered only half of its own stated intent — it caught an omitted idempotency store, not the same class wired by name, which is what the README example does — and never looked at the captcha verifier at all. Both are now rejected by brand, the way the memory adapter and the in-process limiter already are, and IdempotencyImpl republishes its store's brand so the gate reads it without reaching into private state. An always-pass captcha removes protection from every path it fronts; the unconfigured verifier, which refuses every challenge, is still accepted, because failing closed is not a footgun. Breaking for a production deployment that was wiring either one — which is the point: it was unprotected and silent about it.

duck-auth doctor now runs. The command imports your auth.ts and calls strict() on its auth export — with no arguments, though strict(opts) reads opts.compliance before anything else. Every invocation died on a TypeError that the surrounding catch reported as strict() rejected: Cannot read properties of undefined (reading 'compliance'), so a healthy config and a broken one were equally "rejected" and no check ever ran. It now asks about production, the only env whose checks exist; prints meta.detail, which is where the list of failed checks lives, rather than AuthError.message, which is the bare code AUTH_MISCONFIGURED; and says could not run strict() for a failure that is not a verdict, so a crash can never again read as a finding.

strict() now checks every store you hand it, not three of them. The production sweep for in-memory stores was a hand-written list of identities, sessions and credentials, under a comment reading "over every store". Engine.Stores has a fourth slot: orgs. The memory adapter's org store carries the same __isMemoryStore brand the sweep looks for, so a deployment with real identity, session and credential stores beside adapter.orgs passed the gate while its membership rows and role grants — an authorization input — lived in one process, invisible to the rest of the fleet and emptied by a restart. The sweep now reads the stores bag itself, so orgs is covered and a slot added later cannot be missed. Org.Store is a read interface over your own tables, so a production host is expected to have one already; the memory store is the dev stand-in. Breaking for a production deployment wiring adapter.orgs — wire your own org store, or leave orgs unset if the app has no org concept.

The memory device-fingerprint store now refuses a bound it cannot apply. AuthMemoryDeviceFingerprintStore caps how many device fingerprints it remembers per identity and expires them after a TTL, and applied both as a bare > against the configured number. Every comparison against NaN is false, so a non-finite maxPerIdentity or ttlMs -- Number(process.env.FP_MAX) on an unset variable, say -- did not widen the bound, it removed it: one identity retained 500 fingerprints where the default caps at 50, and no sighting ever expired. Zero and negative failed the other way, remembering nothing, so every request read as a new device and stepped up at the detector's default score. Both are now validated in the constructor and throw AUTH_MISCONFIGURED, matching deviceFingerprintDetector and authImpossibleTravelDetector, which already validated their own numeric config. Breaking only for a config that was already not working: the value that now throws was previously disabling the bound it was meant to set.

The SSRF guard now refuses internal host names, not only internal addresses. isBlockedHostname recognised four spellings of localhost and nothing else, so metadata.google.internal -- the name GCP's metadata server answers on, and what an SSRF payload targeting GCP actually uses -- passed the guard that already refuses the 169.254.169.254 behind it. Also now refused: metadata.goog, anything under the ICANN-reserved .internal, and the localhost4 / localhost6 / ip6-localhost / ip6-loopback aliases that ship in the /etc/hosts of Debian-family images. Applies to every outbound URL the library checks: webhook endpoints, captcha verify endpoints and web-push subscription endpoints. Breaking for a deployment that points any of those at a .internal host -- which was already the case for .local and every RFC 1918 address, so this extends an existing posture rather than introducing one.

Outbound oauth and captcha requests no longer follow redirects. OAuthClient's code exchange, refresh, userinfo and authedJson calls, and the captcha siteverify POST, all left fetch on its default redirect-following behaviour, so the request did not necessarily land on the endpoint the scheme and SSRF guards had checked. A 307 or 308 from a token endpoint re-posts the body with client_secret in it to whatever the Location names, and a redirect to http:// puts that secret -- or the bearer token on the userinfo call -- on the wire in plaintext. All five now pass redirect: 'error', the posture the webhook dispatcher already took. Breaking for a provider whose token, userinfo or captcha endpoint answers with a redirect: point the config at the final URL instead.

The Redis/Valkey event bus no longer goes deaf to the fleet when a handler is swapped. RedisEvents.on() recorded a channel teardown by deleting its _subscriptions entry synchronously and issuing the UNSUBSCRIBE several awaits later. Because the shipped valkey adapter unsubscribes a channel rather than a callback, an on() for the same event in that window -- unsub(); bus.on(event, next), the ordinary handler swap -- opened a second subscription that the in-flight teardown then cancelled, and the map was left holding a live-looking entry so no later on() resubscribed. The node kept delivering its own local emits and kept reporting a listener to strict(), while receiving nothing from other nodes for lockout, session.revoked or suspicious. The teardown now stays in the map while it runs and resubscribes if a handler arrived meanwhile. Not breaking: same public surface, and a bus that never swaps handlers behaves as before.

CSRF: bearer clients are no longer refused by the NestJS auth guard, which now also takes a cfg. verifyCsrf only applied its documented bearer exemption when a caller passed isBearer, and the helper that detects one is module-private -- so csrfGuard was the only caller that ever set it. NestJS' makeGuard calls verifyCsrf directly (to reuse the session Nest middleware already resolved) and passed neither isBearer nor a Csrf.Cfg, so a client authenticating with Authorization: Bearer <session token> was refused AUTH_CSRF on every mutating route, and a configured allowedOrigins / headerName was ignored where the CSRF-only guard beside it honoured them. verifyCsrf now derives the flag from the Authorization header when it is not told, and makeGuard accepts cfg. An explicit isBearer still wins. The exemption itself is unchanged -- still only when no cookie rides along, still held to the origin checks otherwise. Behaviour change for a NestJS app using makeGuard with bearer clients: those requests now pass the CSRF check instead of being rejected, matching every other adapter.

WebhookDeliverer now refuses a maxAttempts or timeoutMs that would stop delivery silently. maxAttempts was clamped rather than validated, and Math.min(Math.max(1, NaN), 20) is NaN, so a non-finite value made the retry ladder's while (attempt < maxAttempts) false on the first pass: no request was made for any event and the delivery was dead-lettered with attempts: 0 and an empty lastError. timeoutMs was not checked at all, and setTimeout floors a negative or non-finite delay to zero and overflows past 2^31-1 to zero too, aborting every request before it left. Both now throw AUTH_MISCONFIGURED at construction, as backoffMs beside them already did. The clamp on a finite maxAttempts is unchanged. Breaking for a deployment passing a non-finite maxAttempts (including Infinity, which the clamp silently turned into 20) or a timeoutMs outside [1, 2147483647] -- those configurations were not delivering webhooks, and now say so at boot.

Every store's gc(now) now refuses a cutoff that is not a finite timestamp. The nine implementations across memory, SQLite, Postgres, MySQL and Redis each compared rows against now directly and disagreed about a number that is not one: NaN swept nothing and reported deleted: 0 on memory, SQLite and Redis while Postgres threw, and Infinity deleted every session on memory and Redis while SQLite swept nothing. The silent half is the likely one -- Number(process.env.GRACE_MS) is NaN when the variable is unset -- and for identities it meant the hard-delete that makes a soft delete a delete never ran, while reporting success. All nine now throw AUTH_INVALID_PARAMETERS, and the store-compliance matrix asserts it on all six dialects along with the fact that nothing was swept before the refusal. auth.sessions.gc() and auth.identities.gc() are unaffected -- they take no argument and pass Date.now() themselves. Breaking only for a caller passing a non-finite cutoff straight to a store, which was not collecting anything, or was collecting everything.

A TOTP enrollment can no longer be started over a confirmed one. mfa.beginTotpEnrollment deleted every totp credential for the identity before minting the pending one, so starting an enrollment removed the second factor. The mounted POST /auth/mfa/totp/begin route asks only for a signed-in session, which made it the unguarded way to do what /auth/mfa/totp/remove demands a step-up for -- and a better one, since it also returned a secret the caller controls and confirmTotpEnrollment then regenerated the backup codes. It was invisible as well: removeTotp is what emits mfa.removed, so the deletion reached no audit log. It now throws AUTH_MFA_REQUIRED when a live confirmed enrollment exists; re-enrolling goes through removeTotp, which is guarded. Replacing a pending enrollment still works as before.

Regenerating the MFA backup codes now requires a step-up. POST /auth/mfa/backup-codes/regenerate took any signed-in session and answered with ten fresh plaintext backup codes, each of which completeStepUp accepts as a second factor -- so a caller holding only the password reached AAL2 in one round trip, and the victim's codes were destroyed to mint them. The route now refuses with AUTH_STEP_UP_REQUIRED unless the session is already AAL2, the same gate /auth/mfa/totp/remove uses.

The last-factor lockout guard now agrees with itself. flows.unlinkProvider and identities.unlink each carried their own idea of what still authenticates an identity, and each allowed a lockout the other refused: the first counted an expired password or passkey as live, the second counted one-shot recovery tokens and second factors such as totp as standing ways in. Both now use isStandingFactor -- live, unexpired, and of a kind that can start a session on its own (password, passkey, api-key).

SAML: the email/nameID agreement check now reads the assertion, not the SP config. It fired whenever allowedNameIdFormats merely listed emailAddress, so an SP accepting persistent alongside it refused every persistent login -- an opaque nameID never equals an email. It now keys on the format the assertion states, falling back to the previous behaviour when the IdP omits one. An emailAddress assertion whose email disagrees with its nameID is still refused.

anomalyFacet now refuses a threshold or timeout that is not a usable number. threshold, stepUpAt and denyAt must be finite and within 0..1, and detectorTimeoutMs finite and between 1 and 2^31-1. All four were taken on trust while both shipped detectors validated their own config, and every way they could be wrong failed open: score >= NaN never denies and never steps up, and a delay setTimeout cannot use abandons every detector before it answers, leaving evaluate reporting no signals at all.

Channel sends silently stopped happening on a misconfigured retry ladder. ChannelGuard — which every shipped channel (smtp, resend, ses, twilio, webpush) constructs from its own public Cfg — applied retries and timeoutMs without validating either. A non-finite or negative retries made tries <= this._retries false on the first test, so the provider was never called and the guard threw undefined; Infinity turned the doubling backoff into a tight loop, since setTimeout overflows past 2^31-1 and floors to ~1ms. A non-finite timeoutMs skipped the <= 0 branch that disables the deadline and reached setTimeout anyway, aborting every send before it left. Password resets, magic links, email verification and account-deletion confirmations stopped being delivered, with nothing an error mapper could render. Both are now refused at construction with AUTH_MISCONFIGURED: retries finite in 0..10, timeoutMs finite in 0..2147483647. Both documented zeroes stay legal. This is the sibling of the webhook deliverer ladder fixed earlier in this changeset — same shape, second implementation.

Both rate limiters could be configured into not limiting at all. AuthMemoryLimiter and RedisLimiter validated the weight and key passed to consume, but applied max and windowMs from config unchecked. Measured: max: NaN produced zero refusals in fifty consumes on one key in both limiters — count > NaN is false forever — so the brute-force defence that strict({ env: 'production' }) refuses to boot without was silently not running. windowMs: NaN or 0 is the inverse: the bucket never elapses, so the first budget spent is the last and resetAt reads Invalid Date. Both are now refused at construction with AUTH_MISCONFIGURED — max finite in 1..Number.MAX_SAFE_INTEGER, windowMs finite in 1..8640000000000. Breaking for anyone passing Infinity: an unlimited limiter is a contradiction, and NoopLimiter is the declared way to have none — unlike Infinity, strict() can see and refuse it.

A DPoP proof from any point in time was accepted when the freshness window was misconfigured. The RFC 9449 4.2 check is Math.abs(now - iat) > clockSkewMs + freshnessMs, and > against a non-numeric sum is false, so the check never fired. Measured: with clockSkewMs: NaN, real ES256 proofs dated one year old, ten years old, and one year in the future were all accepted — while a default verifier correctly refuses a one-hour-old proof. Freshness is the primary limit on replaying a captured proof, and the default jti store is per-process, so a fleet has no second line. DPoPVerifier now refuses clockSkewMs or freshnessMs that is not a number between 0 and 3600000, with AUTH_MISCONFIGURED.

A caller could switch off session-hijack and anomaly checks by omitting one header, on the Next.js and gRPC adapters. requestSecurity treated "the host supplied no getCaller" and "this request carried no fingerprint" as the same early return, and every adapter collapsed the two at the call site with ?? {}. nextCaller and grpcCaller deliberately resolve no IP — a Web Request has no peer, and the library will not read a forwarded header — so on those two adapters the User-Agent is the entire fingerprint, and dropping it skipped the hijack evaluation, the suspicious audit record and the anomaly snapshot alike. Hijack.Cfg.onMissingSignal: 'strict' exists for exactly that move and was unreachable, because the return happened before the policy was consulted. Adapters now omit caller entirely when no getCaller is configured, and requestSecurity only skips on that. Behaviour is unchanged on the default onMissingSignal: 'soften' except that the drift is now recorded; hosts on 'strict' get the reaction they configured. A session that recorded no fingerprint is unaffected.

The default password hasher reported a broken install as a wrong password, and threw on a corrupt hash. Argon2idHasher.verify returned argon.verify(...) without awaiting it, so its catch was unreachable: a malformed PHC string rejected out of a method documented to answer false, which also let a corrupted credential row distinguish itself from a wrong password despite the uniform-timing construction around it. The same catch, once reachable, swallowed the AUTH_MISCONFIGURED that loadArgon2 builds to carry the @node-rs/argon2 install command — and since DEFAULT_PASSWORDS_CONFIG selects Argon2id while that package is an optional peer dependency, a deployment that loses the native module told every user "invalid credentials" and the operator nothing. The module load now sits outside the catch and the verify is awaited inside it. A hash from another algorithm still answers false without loading anything.

strict() no longer refuses every HIPAA and FIPS deployment. The AAL-floor gate asked providers.list() whether an mfa provider was registered. list() is the sign-in grid and keeps only capabilities exposing begin/complete; MfaImpl exposes enroll/verify, so it was registered and never listed. Both presets that raise the floor above AAL 1 therefore threw AUTH_MISCONFIGURED saying no mfa provider was registered while one was, in every environment. It now asks providers.has('mfa').

The FIPS hasher requirement is now verified rather than attested. fips declares fipsValidatedHasher, meaning "Argon2id with FIPS params", and ARGON2ID_COMPLIANCE — those exact params, exported by the package — was referenced by nothing: a fips deployment running scrypt booted strict() on the operator typing true. Argon2idHasher and ScryptHasher now publish whether they meet that parameter set, and strict() reads it off the registered password provider. A hasher that publishes no answer is still attested for, since FIPS 140 approves no Argon2 and a host may hold a validated implementation of its own. Also: Passwords.Cfg.hasher was documented as defaulting to scrypt; it defaults to Argon2id.

Backup codes and trusted-device tokens now refuse a length that is not a secret. mfa({ backupCodeLen: 0 }) minted ten codes that were all the literal -, and verifyBackupCode accepted - for any identity; a count that was not a positive whole number deleted an identity's codes and minted none. The same knob was unchecked on BackupCodesFacet (byteLength, count) and RememberMeFacet (byteLength, ttlMs). All three constructors now throw AUTH_MISCONFIGURED for a value outside the usable range, the way toApiKeysCfg already did for randomBytes. Breaking for a deployment that configured a shorter code than the new floors: backupCodeCount/count 1-64, backupCodeLen 8-64, byteLength 5-64, remember-me byteLength 16-128.

Both password hashers now bound their own parameters. scryptHasher({ keylen: 0 }) and ({ saltLen: 0 }) each wrote a row that parse refuses, so sign-up succeeded and the correct password never verified again; ({ N: 2 }), ({ r: 0 }) and argon2idHasher({ memoryCost: 8, timeCost: 1 }) were accepted end to end, with needsRehash calling each result current. The constructors now throw AUTH_MISCONFIGURED for a structurally invalid set — including an N too large for the maxmem both calls pass, which previously threw inside Node on the first sign-up — and strict() refuses a hasher below its own defaults in production only, so a test suite can still use a cheap KDF. A hasher of your own is not judged. Breaking for a deployment configured below keylen 16, saltLen 8, hashLength 16 or saltLength 8, or running production below N 2^14 / r 8 / keylen 32 / memoryCost 19456 / timeCost 2.

An elapsed expiresAt now refuses a credential everywhere, not only on API keys. ApiKeyImpl.verify already treated an expired row as a revoked one, and isStandingFactor — which both lockout guards use to count the ways back into an account — already refused an expired password, passkey or api-key. Seven gates read revokedAt alone: the password sign-in and rehash paths, the passkey allowCredentials offer and the branch that mints the session, remaining() and verify on BackupCodesFacet, and MfaImpl.verifyBackupCode. A password carrying a rotation deadline signed in for ever after it passed, while the engine simultaneously refused to unlink that account's last provider on the grounds it would become unreachable. beginPasskeyRegistration's excludeCredentials is unchanged, so re-enrolling an expired authenticator is still the way back. Breaking for any deployment that writes expiresAt on a credential through the store contract and relies on it being ignored.

CookieTransport's CSRF companion cookie now follows the session cookie's settings instead of four hardcoded literals. { domain: '.example.com' } — the option that exists for cross-subdomain deployments — used to emit the session cookie for the whole domain and the companion as __Host-duck-csrf for the issuing host alone, so a page on a sibling subdomain could not read the token, could not send x-csrf-token, and had every state-changing request refused AUTH_CSRF with nothing said. sameSite and path diverged the same way, and over plain http the __Host- prefix had the browser discard the cookie entirely — previously documented as a WARN rather than fixed. The prefix is now kept exactly when its three conditions hold, revoke() clears under the attributes it set, and a csrfCookieName getter exposes the result for the client's existing option of that name. Breaking: the companion is named duck-csrf when domain is set, path is not /, or secure is false; the default is unchanged.

DPoPVerifier now holds a proof's jti until that proof stops being acceptable, rather than for clockSkewMs + freshnessMs. The freshness check is two-sided, so a proof is live for twice that span, and the old TTL covered only the half after iat — measured at the defaults, the verifier asked for a 90s TTL on a proof that stayed fresh for another 179s, and the identical proof replayed successfully once its jti had aged out. The gap is how far ahead the client's clock runs, which is what clockSkewMs exists to tolerate. NonceStore implementers should honour the ttlMs they are handed per key rather than assuming one global window.

WebhookDeliverer now stamps each delivery attempt as it is sent. Every attempt on the retry ladder used to carry the first one's timestamp, so a retry arrived bearing its position on the ladder as an age, and verifyWebhookSignature refuses a stamp past its tolerance — 5 minutes by default — with a bare false, the same answer as a wrong secret. The defaults stay inside the window, but backoffMs: 60_000 puts attempt 5 at 7.5 minutes and the documented maxAttempts: 20 puts attempt 20 a day and a half out, and both are validated as supported. The body keeps the event time so a delivery's attempts stay byte-identical for idempotency on deliveryId; read freshness from the X-Duck-Timestamp header, which is what the verifier takes.

Email addresses are now folded — trimmed, lowercased and NFC-normalised — when an identity is written, rather than left for each dialect's unique index to fold on read. SQLite's lower() is ASCII-only, and its create leans on that index alone, so an address carrying one uppercase non-ascii letter took a row that nothing could find afterwards: measured, find by the exact address used to create the account answered AUTH_IDENTITY_NOT_FOUND immediately after creating it, and a second account on the same address was accepted. Sign-in by email, password reset and duplicate detection all missed the same way. Trimming closes the same hole for a padded address on every dialect. Breaking, mildly: profile.email now comes back trimmed and lowercased; existing rows are untouched and stay reachable, since lookups still try the stored spelling alongside the canonical one.

isSessionFresh now bounds the freshness window at both ends, as JwtTransport.verify and checkStepUp already did. A rotatedAt ahead of the clock gave a negative age, which is under any window, so a session stamped a day out read as permanently fresh — and freshness is what gates a password change. Nothing bounds that column from above and the stamp is written by whichever node rotated the session, so one machine with a fast clock handed the rest of the fleet sessions that never went stale. Ordinary node skew inside the window still reads fresh.

IdempotencyImpl.handle now releases the key when the executor throws. The claim was written with the response TTL — a day by default — and nothing let go of it, so a client retrying under the same key after an error read a miss, lost the claim, polled out and was answered 409 idempotency-conflict for the next 24 hours, with the work never running again. That is the one case an Idempotency-Key exists for. A response the executor did return is still cached and still not re-executed; a process that dies mid-executor still falls back to the TTL.

MfaImpl now treats an elapsed expiresAt as revocation, as every other credential kind already did. Six readers - both TOTP paths, both WebAuthn-MFA paths and the two has* probes - filtered on revokedAt alone, so a deadline written on a totp or webauthn-mfa row was honoured by nothing: the second factor kept verifying past it, the assertion challenge kept offering the credential, hasTotp kept reporting the enrolment to beginPasswordReset, and the enrolment guard kept refusing to replace it. A credential whose deadline has not passed, or that has none, behaves exactly as before.

AuthEngine.strict() now rejects AuthConsoleChannel, AuthNoopChannel and AuthTestChannel in production, as it already rejects the memory adapter, the in-process idempotency store and the always-pass captcha verifier. Each of the three refused itself on NODE_ENV and nothing else, and that check does not fire where NODE_ENV is unset - so a deploy could log every magic link to stdout, or report every password-reset email as delivered without sending one, and pass strict(). The three now publish a brand strict() reads off the channels bag; a real channel is untouched, and all three stay accepted under env: 'development' and 'test'.

IdentitiesImpl.exportAll now narrows its session list to the tenant context it is given, as it already narrowed the credential list one line above. The GDPR right-to-access blob read every session the identity had anywhere, so in a multi-tenant deployment one tenant's export named the other tenant's sessions and carried their tenantId, IP, user-agent, fingerprint, assurance level and factor list. Identities are global while sessions are tenant-scoped, which is what makes the unfiltered read cross a boundary. A caller that passes no tenant, which is every single-tenant deployment, gets exactly the same export as before.

5.12.0

Minor Changes

  • Return signatures: the engine answers with the row or rejects, and a caller who wants absence as a value asks for it.

The engine answers with the row or rejects

Every public facet method used to answer null for two different things — the row was not there, and the store could not be reached. A caller could not tell them apart, which is how a sign-up reads a timed-out lookup as a free email address.

Each of these now rejects with an absence code instead, and .orNull() restores the old shape wherever the old shape was wanted:

  • identities: getById, getByEmail, getByProviderSub, softDelete, restore, erase → AUTH_IDENTITY_NOT_FOUND
  • sessions: getBySid, touch, revoke, revokeByHash → AUTH_SESSION_REVOKED
  • orgs: get → AUTH_ORG_NOT_FOUND; resolveMembership, removeMember, setRoles → AUTH_MEMBERSHIP_NOT_FOUND
  • flows.getSignUpFlow, apiKeys.revoke, the remember-me facet's verify
  • AuthEngine.resolveSession → AUTH_SESSION_REVOKED, with the reason on meta.reason

Every one of these returns Answer.Me<T>, a promise carrying three readers: orNull(), orDefault(fallback) and wrap(). orNull and orDefault swallow only the absence codes in the exported ABSENT set; everything else still throws. wrap() never rejects and hands back { data, error }.

resolveSession also gained a code that is deliberately outside ABSENT: AUTH_SESSION_IDENTITY_ERASED, for a live session whose identity no longer resolves. It is a data-integrity violation rather than a sign-out, so .orNull() does not eat it — it stays loud at every call site, including all sixteen framework adapters.

A refused argument is no longer a missing row

An empty, oversized or non-string sid, and the other malformed arguments a caller holds, now reject AUTH_INVALID_PARAMETERS rather than reporting as absence. orNull() deliberately does not swallow it: a bug in the calling code should not read as "no such session". An untrusted inbound token is different — that is a verdict, and stays AUTH_SESSION_REVOKED.

Writing an adapter no longer means producing Answers

The adapter layer is internal and plain: throw, or return the value. Removed from @gentleduck/auth/adapters: Adapter.Wrapped, Adapter.Answer and Adapter.Result. Adapter.Me now holds the store contracts directly rather than wrapped copies of them. Failed<C> is no longer exported from @gentleduck/auth/core/errors.

A store contract answers a row or throws. Absence is the store's own code — for instance Credential.Store.findById rejects AUTH_CREDENTIAL_NOT_FOUND where it used to answer null — and the facet in front of it is where absence becomes a value again. The list forms are unchanged: zero rows is a list, not an absence.

Pluggable store contracts reject a miss

If you implement one of these yourself, it breaks at compile time. No adapter shipped in this repo is affected; these are the host-supplied ones.

  • Idempotency.Store.get → Promise<CachedResponse>, rejecting AUTH_IDEMPOTENCY_MISS (new code) for a key never seen, an elapsed TTL, a row that no longer parses, **and a key still holding the tombstone claim() wrote**. All four must reject, or the claim/poll protocol serves a status-0 placeholder to a racing caller as if it were a real response.
  • Operations.Store.load → Promise<State>, rejecting AUTH_OPERATION_NOT_FOUND (new code) when nothing has been persisted yet. A store that is down must reject its own code: hydrate() runs at boot, and reading an unreachable store as "nothing is switched on" brings a node back serving traffic through a maintenance freeze.
  • Passkey.ChallengeStore.take → Promise<string>, rejecting AUTH_CREDENTIAL_NOT_FOUND for a key never put, an elapsed TTL, and a challenge already consumed.

Both new codes join ABSENT, so orNull() reads them back as null.

Verifiers and the budget guard

Transport.ITransport.verify and revoke lost their | null. This is a compile-time break only: a stateless verifier that cannot vouch for a token is stating a verdict, so it throws rather than shrugging.

ChannelGuard.spend now returns Answer.Me<void> and rejects AUTH_RATE_LIMITED once the send budget is spent, instead of answering a prose string. This changes what the five channels put in SendResult.error: it is now the code "AUTH_RATE_LIMITED" rather than "smtp: send budget exhausted, next at <iso>". The reset time is not lost — it moves to meta.retryAfter as whole seconds, floored at 1, which is the machine-readable form a retry actually wants. retryable: true, which is what callers branch on, is unchanged.

Two capabilities became throwing getters

AuthEngine.orgs and the transaction facade's orgs were OrgsImpl | null properties. Both are now getters that throw AUTH_PROVIDER_NOT_REGISTERED when no org store was configured, matching how passwords, mfa and apiKeys already behaved. The message names stores.orgs rather than a provider, because there is no orgsProvider() to add.

Single-use tokens can no longer be redeemed twice

flows.signIn with a magic-link token, flows.completePasswordReset, flows.verifyEmail, flows.completeAccountDeletion and the OAuth refresh exchange all claimed their credential row with a compare-and-set and revoked or deleted it in a second statement. The claim rotated the secret to the value it already held, so between the two writes the row was still findable by the token's hash and still unrevoked — only its version had moved. A second redemption landing in that window read the live row at the next version, won its own compare-and-set, and went through.

For a magic link that is two sessions from one emailed link. For a password reset it is a second reset: the loser's password was the one left on the account, set after the winner had already revoked every session. Two people holding one reset link both got to use it, and the later one won.

It needed concurrent requests to hit, which a shared inbox, a mail scanner prefetching links, or a double-click all produce.

For OAuth refresh the window was wider still — it spanned the whole outbound token exchange, and a second presentation got a valid token set of its own instead of tripping the reuse detection that RFC 6749 section 10.4 is built on.

Email verification and account deletion are the mild pair: the second run re-verifies a verified address, or re-deletes a deleted account. Account deletion already came out with the right answer, but by accident — the loser was stopped a line later by softDelete reporting an already-hidden row as a miss, not by its own claim. It also minted and mailed a second cancellation link on the way.

flows.completeSignUp had the same outcome by a different route: it never took a compare-and-set at all. It read the flow row, ran the whole tail — profile merge, email-verified flag, session issue — and revoked the row at the end, unconditionally. The window was the entire flow, and two concurrent completions each walked away with a session of their own.

All six now spend the token in a claiming write, rotating the secret off the presented hash so the row stops matching it the instant the winner lands. A racer that read before the claim still loses on the version; one that reads after no longer finds a usable row. The four recovery flows answer AUTH_RECOVERY_TOKEN_INVALID and sign-up answers AUTH_SIGNUP_TOKEN_INVALID, as before.

OAuth rotates to a derived hash rather than a random one, so the claimed row stays findable and a replayed refresh token still revokes its whole family. Two notes for OAuth callers:

  • A refresh whose provider exchange throws releases its claim, so the refresh token keeps working — the same as before this change.
  • After a successful refresh, the old refresh token's hash no longer resolves to a credential row at all. Presenting the old token behaves exactly as it did: AUTH_OAUTH_REUSE_DETECTED, with the family revoked.

flows.cancelAccountDeletion given a token was already exclusive — it deletes the undo row before it restores, and that delete refuses a row another cancellation has taken — but the loser came back with AUTH_CREDENTIAL_NOT_FOUND. That code means "no row" to orNull and orDefault, so a caller reading absence saw a lost race as "there is no such token". It now answers AUTH_RECOVERY_TOKEN_INVALID, the same as every other refusal on that path. No account was ever wrongly restored or left deleted; only the reported code changes.

mfa.backupCodes.verify had the same defect and is fixed the same way. It listed an identity's codes, matched one that was not revoked, and revoked it in a second, unconditional statement, so two verifications arriving together both matched the same live row and both returned true — a single-use recovery code was reusable. The match is made against the stored secret, so burning it in the claiming write closes the window outright.

mfa.verifyTotp was reusable for the same reason, against a docstring promising single use within the window per NIST SP 800-63B. It read the last accepted step off the credential's metadata, compared, and recorded the new step in a second, unconditional write, so two verifications arriving together both compared against the same stale step and both returned true — one code, two step-ups. Burning the secret is not available here, because a TOTP secret is shared with the authenticator app and has to survive, so the comparison and the record became a single conditional write and the loser returns false. mfa.confirmTotpEnrollment is deliberately left unconditional: two concurrent confirmations agreeing on the same outcome is the right answer.

If you ship a custom credential store, this is the one thing to check. Credential.Store.patchMetadata takes an optional fourth argument, expectedVersion. Given one it is a compare-and-set: a row whose version has already moved rejects AUTH_STALE_WRITE and nothing is written. Omitting it behaves exactly as before, so existing stores keep compiling and every other caller is unaffected — the hazard is a store that accepts the argument and ignores it, which loses the guard without failing anything. runCredentialStoreCompliance from @gentleduck/auth/test now covers it, so run it against your store. One detail it pins: a conditional UPDATE cannot tell a missing row from a stale one, so when expectedVersion is given an unknown id also answers AUTH_STALE_WRITE; without it, AUTH_CREDENTIAL_NOT_FOUND, as before. All four bundled adapters — memory, SQLite, Postgres and MySQL — honour it.

One behaviour change worth knowing: because the token is now spent before the work rather than after it, a completion that throws part-way leaves the link used up. flows.verifyEmail, flows.completeAccountDeletion and flows.completeSignUp could previously be retried with the same link after a failed profile or identity write; now the user needs a fresh one. That is the trade being made — the alternative is the double execution above — and magic-link and password reset already behaved this way.

Nothing to do on upgrade unless you implement Credential.Store yourself, where patchMetadata's new optional argument is the one thing to honour.

WebAuthn clone detection now works on the MFA path, and survives concurrency on both

The signature counter is the whole of WebAuthn L2 section 6.1.3 clone detection: an authenticator that has been copied shows up as an assertion whose count no longer exceeds the stored one.

mfa.verifyWebauthnMfa never stored it. It read the count, ran the comparison and returned without writing, so the stored value stayed at whatever enrollment recorded — zero for a synced or platform authenticator — for the life of the credential. Against a stored zero the comparison can never fire, and against a hardware key registered at N every count above N passed forever. A cloned authenticator was not detected on this path at all, with no timing needed. It is now recorded on every accepted assertion, so the comparison measures against the last one rather than against enrollment.

passkey.complete did store it, but in a second, unconditional write. Two assertions arriving together both read the same stored count, both cleared it and both got in — the clone waved through for arriving alongside the real authenticator instead of after it. The comparison and the record are now a single conditional write on both paths; a lost race means another assertion has already moved the count past this one, so it reports as the rollback it is. passkey.complete throws AUTH_PASSKEY_MISMATCH with a passkey-counter-rollback signal, and mfa.verifyWebauthnMfa returns false with webauthn-mfa-counter-rollback, exactly as each already did for a sequential rollback.

Authenticators that do not count are unaffected: a reported count of zero — which every iCloud Keychain passkey sends on every login — still short-circuits before the write, so nothing is stored and nothing is refused.

A SAML assertion with no ID no longer skips the replay store

saml.complete consumes the assertion id through the replayStore you supply, which is what stops a captured response being posted twice. It only ran when the assertion carried an ID, and an assertion without one fell through the check and signed in — so a deployment that had explicitly wired replay protection had none for a body shaped to omit it, and nothing reported the guard had been skipped. SAML 2.0 core requires ID on an assertion, so its absence means the assertion is malformed rather than that there is nothing to check.

An assertion with no usable ID is now refused whenever a replayStore is configured, through the same redacted refusal as any other bad assertion: the operator audit gets the reason, the caller gets AUTH_PROVIDER_FAILED. Blank and whitespace ids are refused with it — used as a key, '' would be consumed once and then lock out every later assertion that also lacked one. Ids longer than 256 characters are refused before they reach your store.

If you do not configure a replayStore, nothing changes. If you do, and your IdP genuinely omits assertion ids, sign-ins that previously succeeded will now be refused — that is the bug being fixed, and the fix is an IdP that emits conformant assertions.

An unreadable impersonation window no longer reads as "not an impersonation"

session.actingAs is the envelope that makes a session an impersonation: the real operator, the reason, and when the window closes. null means the session was never one, so resolveSession skips the expiry check — correctly, since there is no window to close.

The drizzle adapters answered null for a second case: a column that was present but could not be read. A session that was an impersonation then loaded as an ordinary session belonging to the person being impersonated — the operator's expiry cap gone, and the audit envelope that events.audit.ts and actor.request.ts use to name the real operator gone with it, so the trail said the user did it.

All three dialects now refuse such a row with AUTH_SESSION_REVOKED, which is what the redis store already did for the identical input. An absent column still reads as null, because that is what an empty column means. factors still degrades to [] when unreadable — that can only lower the AAL a session claims, where an unreadable window only removes a restriction.

The same applies when the column holds bytes that are not JSON at all. fromJsonColumn used to answer null for an unparseable string, which made a corrupt column indistinguishable from an empty one before either parser saw it; it now hands the string through, so the parsers decide. This mattered most on sqlite, where every JSON column is raw TEXT.

You will see this only if an acting_as column has been corrupted or hand-written in a shape the library does not produce. If you have such rows, they now fail the read rather than loading under-restricted.

An unsafe redirect is now refused for the whole response, not just its own intent

executeIntents (the Web-Fetch executor in server/generic) checks every redirect intent with isSafeRedirectUrl and answers AUTH_MISCONFIGURED when one fails. The check ran, and the unsafe Location was never set — but the refusal only broke out of its switch, so the loop carried on and the next intent reassigned the status and body. A following json intent turned the 500 into a 200 success, and a following safe redirect turned it into a 302 that actually redirected. The express executor returns at that point, so the two disagreed on the same intent list.

The refusal now ends the response in both, with any cookies already staged still sent, which is what express did all along. If you write providers that emit a redirect intent followed by more intents and rely on those later intents running after a rejected URL, they now stop — but a rejected URL already meant the redirect config was wrong.

csrfGuard and verifyCsrf skipped every CSRF check for a request carrying Authorization: Bearer, on the reasoning that a header credential is not ambient and so cannot be forged cross-site. Neither checked whether the bearer was what authenticated the request. A cross-site request carrying the victim's session cookie and any Authorization: Bearer value — the token never had to be valid — took the exemption, authenticated by cookie, and skipped the check, including the Origin / Sec-Fetch-Site gate that is the actual cross-site defence. Reaching it needs a CORS policy that allows the Authorization header with credentials, which is a common one.

The exemption now applies only when the request carries no cookie, which is the condition its own reasoning always assumed. A request with a cookie is held to the origin checks whatever header it adds, and still skips the double-submit token when it is genuinely bearer-authed.

Nothing changes for an API client that sends no cookie, including a browser client on another origin holding its token in memory. If you have a browser client that sends both a session cookie and a bearer token cross-site, those requests are now refused with AUTH_CSRF; send the x-csrf-token header, or stop sending the cookie.

Redaction no longer hands the secret through

Two functions on egress paths stripped secrets for some inputs and not others.

redactSecrets returned a whole unwalked subtree once it passed its depth cap, so a payload nested more than eight levels deep carried its secrets out in full while the value read as redacted. It is the default redact for WebhookDeliverer, so that subtree went out in an HTTP POST to the endpoint you configured. Past the cap it now answers '[depth-cap]', which is what scrubMeta next door already did. A consequence: a self-referencing payload used to stay circular through redaction, fail JSON.stringify and dead-letter; the cycle is now cut at the cap, so it delivers truncated instead.

redactProviderError matched api_key=x but not "api_key":"x" — the quote sits where it expected the separator — so the JSON body an HTTP SDK throws went through unredacted, as did Authorization: Bearer <token> and any credential a provider puts in the URL path (Telegram's bot token, Twilio's account SID). It now handles quoted labels, auth schemes, and opaque path segments, while keeping route names like /v1/send so a failure still says which call it was.

If you pass your own redact to WebhookDeliverer, nothing here changes it. If you relied on reading a credential back out of a channel's error string, you no longer can.

Encryption at rest: a kid that silently destroyed data, and unchecked GCM parameters

AuthAesGcmDataAtRest writes <alg>$<kid>$<iv>$<tag>$<ct>, and took the operator's kid verbatim. A kid containing $ encrypted without complaint and then split into six parts on the way back, so every read of everything written under it failed as malformed — the write path never said a word, and the damage was only visible once the data was already unrecoverable. A kid is now refused at construction if it is empty or contains $, for the current key and for every previousKeys entry, which is where a rotation would otherwise smuggle one in. AuthKmsEnvelopeDataAtRest already percent-encoded its key id for exactly this reason.

AuthKmsEnvelopeDataAtRest did not check its IV or auth-tag length before handing them to Node. Node accepts an AES-GCM tag shorter than 128 bits — it only emits a deprecation warning — so a forgery against a truncated tag was a 2^32 problem rather than a 2^128 one, on the column whose entire job is to hold when an attacker can already write to the database. It now requires a 12-byte IV and a 16-byte tag, as AuthAesGcmDataAtRest next door already did, and zeroes the unwrapped DEK on those refusal paths too.

If you configured a kid containing $, your adapter now throws AUTH_MISCONFIGURED at startup instead of at every read. Anything already written under such a kid was never readable.

Federated sign-in no longer invents an email verification nobody made

onFederationConflict: 'link-if-verified' hands an existing account to whoever signs in with its address, on the strength of one flag. Two providers set that flag without a provider having said so.

Microsoft marked every address verified because one was present. Entra's email is mutable, set per-tenant, and documented by Microsoft as unverified — their own guidance is never to use it for authorization — and the provider's default tenant: 'common' accepts a sign-in from any Entra tenant, including one the attacker created. Under 'link-if-verified' that was a cross-tenant account takeover: set email on your own tenant's user to the victim's address and sign in. The email is now carried but never asserted as verified, which is what every other provider here already did — they key on an explicit claim, and Microsoft sends none.

Apple read an absent email_verified as true. Apple sends the claim whenever it sends an email, in either the boolean or the string spelling, so this only ever covered the case where Apple said nothing — and it resolved that case the dangerous way. Now only a claim of true counts.

If you use 'link-if-verified' with Microsoft, linking by email stops. That is the fix, not a regression: nothing had verified the address. Link from your own onFederationConflict callback if you have proved domain ownership out of band.

Sign in with Apple could not begin a flow at all

APPLE_ENDPOINTS spelled "Apple has no userinfo endpoint" as userinfoEndpoint: ''. The client validates every endpoint it is given as an http(s) URL, and an empty string is a string, so buildAuthorizeUrl threw AUTH_MISCONFIGURED before a redirect was ever produced — every Apple sign-in, every time. The key is now absent, which is the spelling the validator and OAuthClient.userinfo both already treated as "not configured". The provider's only test covered decodeIdToken in isolation, so nothing exercised the flow; there is now an end-to-end one.

Documentation that promised more than the code did

OAuth.BeginInput.returnTo said "library appends to the front-end after callback". It does not: complete answers with clearCookie + startSession, and authVerifyState is not exported from any entrypoint, so the value is signed into the state, length-capped and then read by nobody. The doc now says so, and records that routing it to a redirect intent later has to go through isSafeCallbackPath first — an attacker who calls begin themselves gets a validly signed state for any target they like.

authRefreshoauthToken — OAuth refresh-token rotation with reuse detection — is imported by nothing and exported from no entrypoint, so no consumer can reach it, while complete still writes the familyId and generation metadata it reads and AUTH_OAUTH_REUSE_DETECTED sits in the public error catalogue. It is flagged in the source; wiring it up needs a ./providers/oauth entrypoint and is left as a decision rather than taken here.

IdempotencyImpl.handle said it scoped by identity "so one caller's cached response is never replayed to another". True only with an identityId. Without one every anonymous caller shares the '_anon' bucket, which makes the Idempotency-Key the entire authorisation to read that response back. The behaviour is unchanged and now has a test pinning it; the comment no longer claims otherwise.

A requireMfa intent is acted on rather than dropped

Provider.Intent's contract says FlowsImpl "consumes and strips" the internal startSession and requireMfa signals before an adapter sees them. It only stripped requireMfa. A provider answering startSession alongside requireMfa therefore got a plain session at the AAL it asked for, and the demand for a second factor left with the filter; one arriving alone produced an outcome with no session, no sid and no intents, which tells a caller nothing. signIn now throws AUTH_STEP_UP_REQUIRED carrying the requested methods.

None of the six shipped providers emits one, so nothing changes for them. The provider list is open, and this is what a custom provider's requireMfa always meant.

A failed plugin install no longer leaves its providers behind

PluginRegistry.install promised "all of the plugin or none of it" and registered providers first, before the event handlers, the facet and the install hook. Providers has no unregister, so a hook that threw left that plugin's sign-in providers registered and reachable through signIn for the life of the engine — belonging to a plugin that is not installed, whose install never ran, and whose facet and event subscriptions had just been rolled back around them. The existing test for a throwing hook asserted the facet and the events and said nothing about the providers.

Providers now register last, after the hook, because that is the one step that cannot be undone and nothing that can throw may follow it. A colliding provider id is therefore reported after the hook has run rather than before; a hook's side effects were never rollback-able either way, and the plugin id stays free for the retry.

Session freshness was measured in one direction only

fresh is what privileged operations branch on, and both gates that compute it subtracted rotatedAt from now without bounding the result below zero. A timestamp in the future yields a negative age, which is under any window, forever — so JwtTransport.verify reported fresh: true for the life of every token whose frsh was minted ahead of the verifier's clock, and flows.checkStepUp never found such a session stale enough to re-challenge. frsh is written by the issuing server and read by the verifying one, and nothing makes those the same clock: one instance running ahead of its peers, or a single NTP step backwards, was enough to disable the step-up freshness requirement silently and indefinitely.

Both now bound the age in both directions, the way authVerifyState already bounded its own iat. Skew smaller than the freshness window still reads fresh, which is what a session issued a moment ago needs.

DPoP: the jkt comparison is the caller's, and nothing said so

DPoPVerifier.verify proves the presenter holds the key carried in the proof — a key the presenter chose. It cannot know which key the access token was issued against, so the returned jkt is the entire binding. A caller who reads verify not throwing as "DPoP checked" gets none of it: whoever steals an access token can mint a proof with their own keypair, and the ath, htm, htu, iat, jti and signature checks all pass, because every one of them is a statement about the request they are making and the token they are holding.

No behaviour changed — the verifier is correct and this is the protocol working as designed. What was missing is that nothing told the caller the returned value was load-bearing: bindPayloadToDPoP writes cnf.jkt and is called nowhere, no doc in the package mentioned jkt or cnf, and the tests asserted only that a thumbprint came back. Verified.jkt now carries the requirement, the class docstring no longer claims the binding unconditionally, and two tests pin what "did not throw" is worth on its own.

SAML: a client that verifies no signature is refused at construction

samlProvider takes a pre-built @node-saml/node-saml client, and that client is the only thing in the flow that checks a signature — nothing else in the wrapper can tell a forged assertion from a real one. A client configured with wantAssertionsSigned: false and wantAuthnResponseSigned: false accepts any XML that parses, from anyone who can reach the ACS URL, and the wrapper had no opinion about it. It now refuses that pairing at construction, through the same .options probe the callbackUrl check already used. Only the pairing: signing the assertion alone is the common case and still passes, as does a client that exposes no options.

DEFAULT_SAML_CONFIG's comment claimed both flags were "on by default", which overstated what they do — they are read only when generating SP metadata, a document handed to the IdP, and never reach the verifier. The comment now says which is which.

wantAuthnResponseSigned did nothing at all

In the fallback metadata renderer it was resolved from config and then consumed by ${wantAuthnResponseSigned ? '' : ''} at the end of the document — empty in both branches, so true and false produced byte-identical XML. The expression is gone. SPSSODescriptor has no attribute for response signing, so there is nothing standard to emit in its place; the option is now documented as inert rather than left looking wired, and is a candidate for removal in a major.

Also documented, not changed: an assertion carrying no NameID Format skips allowedNameIdFormats entirely. SAML 2.0 §8.3 reads an absent one as unspecified, which the default allowlist does not contain, so enforcing it would refuse every IdP that omits the attribute — and many do. The signature still binds the assertion to the IdP, so this bounds IdP misconfiguration, not an attacker.

A TOTP secret that decodes to nothing verified anything

verifyTotp and matchTotpStep refused a secret that failed to decode, but not one that decoded to zero bytes — '', or a string of only padding and spaces. Node builds a working HMAC from an empty key, so such a row went on producing perfectly valid six-digit codes, derivable by anyone from the clock alone, while hasTotp reported the account as protected and eligibleAal counted it toward AAL 2. Anyone who could reach the step-up endpoint for that identity passed it.

Reaching it needs a row written with such a secret rather than one this library minted — beginTotpEnrollment always generates 160 bits. The memory adapter refuses a secret that trims to nothing, but that guard is the memory adapter's alone: the drizzle adapters have none, so on a SQL deployment a plain empty string stores without complaint, and padding-only passes even the memory guard. The check therefore belongs where every adapter and every host-written store passes through it, which is the verifier, and that is where it now is.

Not enforced: RFC 4226 §4 requires at least 128 bits and this generates 160, but the floor is left unchecked because a deployment may hold shorter secrets imported from an authenticator that mints 80. Zero bytes is refused because no secret is legitimately that.

"Forget this device" deleted whichever credential you named

RememberMeFacet.revoke takes a credential id off a device-management request. It checked the row belonged to the caller and then deleted it — with no check of what kind of credential it was. Pointed at their own TOTP enrollment it removed their second factor; pointed at a passkey it removed that; pointed at the password-reset token sitting in their inbox it cancelled that. All silently, and without the events the real removal paths emit, so an account could lose its MFA through an endpoint whose job was to forget a browser. Everything else that reads or deletes over the shared recovery kind filters on metadata.purpose; this was the one that did not, and now does.

list and revokeAll also disagreed about what a trusted device is. list promised the live ones and included any whose TTL had elapsed, so a user's device list showed entries that could no longer skip MFA. revokeAll was built on list, which meant "wipe every trusted device" skipped exactly the expired rows and left them in the store permanently. list now filters on expiry and revokeAll sweeps the rows itself, expired ones included.

Backup codes: byteLength was a setting that did nothing

BackupCodesFacet.Cfg.byteLength was declared, defaulted and never read — the length was the literal 8 at the call site. Its sibling RememberMeFacet wires the identically named option, which is what made the difference visible. An operator raising it to harden their recovery codes got the same 40 bits as before, silently. It is now wired: the alphabet carries five bits a character, so the default of 5 bytes is the same 8 characters it always was, and 10 bytes is 16.

Two things that had to move with it. groupFour split once at position four, so anything longer than eight characters came out as one group of four and one of everything else; it now groups every four. And _normalize claimed in its docstring to de-hyphenate and instead added a hyphen, on a hardcoded length of 8 — it now strips what the user typed and re-applies the canonical grouping, so where they put the hyphens stops mattering. verify also gained an upper length bound; it had a floor but no ceiling, so a multi-megabyte string was uppercased and sha-256'd on every attempt.

A cloned passkey announced itself and was let in

WebAuthn's signature counter is the only clone-detection signal a relying party gets, and Level 2 section 6.1.3 states the comparison over the pair: "if either authData.signCount or credentialRecord.signCount is nonzero" and the new value is not greater, the authenticator may be cloned. The check here read only the new value — newCounter !== 0 && newCounter <= oldCounter — which excuses exactly one transition: an authenticator that had counted to five now reporting zero. That is a count that went backwards, and it is the textbook clone signature. It is now the spec's condition, so the case is refused and reported as passkey-counter-rollback like every other regression.

The zero that the old condition was protecting is a different authenticator: one that implements no counter reports zero at registration and forever after — every passkey synced through iCloud Keychain — so its stored counter is zero too, neither side is nonzero, and the comparison is skipped as it always was. Nothing about synced passkeys changes.

Three unit tests asserted the old behaviour, under a comment about synced passkeys that describes the case they did not test: they seeded a stored counter of five. A fourth, in passkey.test.ts, is commented "presents a counter of 0 against a stored counter of 5" and passes newCounter: 3. The bundled @simplewebauthn/server has always refused this input itself, so no deployment on the real verifier was letting a rollback through — it was throwing a raw Error instead, which is the next item.

The WebAuthn verifier's own failures left as 500s

verifyAuthenticationResponse and verifyRegistrationResponse signal every check of their own — bad origin, wrong rpID, counter rollback, unparseable attestation — by throwing a plain Error. Nothing between the passkey provider and the consumer's handler caught one, so it escaped unmapped, and the counter message carries the stored count: Response counter value 0 was lower than expected 5. Both call sites now answer AUTH_PASSKEY_MISMATCH, which is what every other refusal on those paths already answered.

One consequence worth stating: when the bundled verifier performs the counter check itself it throws before the provider's branch runs, so the refusal is correct but the passkey-counter-rollback event is emitted only when a verifier module returns rather than throws.

A passkey's user-handle binding could be switched off by the request

response.userHandle binds the assertion to the identity the credential is stored against. It was read as string from an unknown request body and handed to Buffer.from, which throws on a number or an object; the decoder answered null for the throw, and the caller treated null as "no handle was sent" and skipped the comparison. Sending userHandle: 1 therefore turned the check off. A handle that is present and not a string is now refused. A handle that is absent is still absent, which the spec allows for a non-discoverable credential.

The passkey verifier was handed the wrong credential id

verifyAuthenticationResponse was given credential.id as the storage row's uuid rather than the WebAuthn credential id, which registration stores verbatim in secret and which _resolveAllowList already reads from there. The library only echoes the value back today, so the right one and a uuid are indistinguishable at runtime — which is how it survived. No behaviour changes; it stops being a latent break if the library ever compares it to response.id.

A scrypt hash with its key field emptied verified every password

ScryptHasher.verify derives its candidate at the stored key's length. Node's scrypt answers an empty buffer for a keylen of 0 rather than throwing, and timingSafeEqual calls two empty buffers equal — so scrypt$16384$8$1$<salt>$ accepted anything, including the empty string, in constant time and with nothing logged. A column narrowed by a migration or a half-finished write is enough to produce such a row, and verify reads exclusively from storage. parse now refuses an empty key or salt, which needsRehash reads as "rehash this", so the row is replaced the moment its owner signs in properly.

Argon2idHasher is unaffected: it hands the encoded string to @node-rs/argon2, which parses it itself. The bug is in the hand-rolled encoding, and it is the default hasher.

A password parameter upgrade never rolled out

autoRehash is on by default and had no test anywhere in the package. It could not have worked on a real store. verify fires an opportunistic rotate at the row to stamp lastUsedAt, which bumps the version; rehash then read the row, spent the whole argon2 or scrypt hash, and compare-and-set on the version it had read. The touch lands inside that window every time, so the rotation failed with AUTH_STALE_WRITE — which the caller swallows on purpose, being opportunistic. rehash now hashes before it reads, so it is not holding a version across the hasher's cost.

The memory adapter applies a rotate synchronously, which is why no existing test could see this; the new one wraps the store so its writes land a tick later, as a driver's do.

maxLength above 1024 locked people out of the password they had just set

complete capped the incoming password at a literal 1024 while set validated against the configured maxLength — the comment above it even claimed the two matched. Raising the setting therefore produced passwords that could be chosen and never used again. The entry point now reads the setting. Lowering it was already consistent, verify having capped there all along.

Two rate-limit refusals answered a 500 instead of a 429

refuseRateLimited reads resetAt defensively, because Limiter.Me is the host's interface to implement and one backed by redis — or by anything with a JSON hop that forgets to revive the Date — hands back epoch milliseconds. Its docstring says every limiter guard goes through it. Two did not: AuthApiKeyImpl.complete and ChannelGuard.spend each built the refusal by hand with a bare resetAt.getTime(), which throws on a number. The guard whose only job is to answer AUTH_RATE_LIMITED answered a TypeError instead, and the channel one carried a comment pointing at the api-key line by number as the thing it was copying. Both now call the shared refusal, whose events argument accepts null for a guard with no bus in reach — neither has a subject to name, the token being unverified at that point and a send being bucketed by channel and tenant.

randomBytes below 16 is refused instead of minted

randomToken(0) answers '' without complaint, so apiKeyProvider({ randomBytes: 0 }) made every key the facet ever minted the literal string ak_live_ — the prefix, which is a documented constant. One or two bytes is the same problem wearing a number, and nothing in the library throttles the guessing: the sign-in limiter buckets by the hash of the token presented, so an attacker trying a different key each time never meets the same bucket twice. toApiKeysCfg now refuses anything that is not a whole number of bytes at 16 or above, at wiring time rather than quietly raising it, so the mistake is answered where it was made. Keys already issued are unaffected — verify compares a hash and never reads a length — and the compliance presets, all of which are 32 or 48, still ratchet above it.

The memory limiter kept every key it had ever seen

Its bucket map was never pruned. The key is whatever a request can name — an email for the password guard, an identity id elsewhere — so a flood of distinct addresses grew the map for as long as the process lived, and nothing stopped that reaching production: strict() refuses the noop limiter, not this one, whose docstring is the only thing saying "dev/test only". Elapsed buckets are now dropped when the map crosses a threshold, amortised rather than swept on every new key.

The live set stays unbounded on purpose: evicting a bucket that has not expired hands its owner their budget back, which is the whole limit. A deployment facing that flood wants the Redis limiter, whose buckets expire in the store. Both points are now in the class docstring.

A password reset in a multi-tenant deployment locked the account out

completePasswordReset resolved the tenant from its input and passed it to every store call it made — the token lookup, the compare-and-set that burns it, the revoke — except the last one, the write that actually swaps the password. PasswordsImpl.set defaults its tenant context to {}, so the omission type-checked.

An undefined tenant is not "this tenant": inTenant emits no filter at all, and create stamps tenantId: null. So the delete inside set reached that identity's password row in every tenant, and the replacement it wrote belonged to none of them — invisible to the tenant-scoped read that sign-in does. Completing a reset locked the account out of the tenant that asked for it, took the other tenants' passwords with it, and left another reset as the only move, which did the same thing again.

Single-tenant deployments never saw this: with no tenant anywhere, the unscoped write is the only write there is.

A reset still ends sessions in every tenant, not just its own. That is the safe direction and it is left alone.

Orgs: what the tenant argument and the event bus actually do

Auditing the orgs facet for the same shape turned up three things worth stating plainly rather than changing, since none of them is a bug in shipped code:

Org.Store requires a TenantContext on all six methods, OrgsImpl defaults it to {} and threads it through faithfully — but the only shipped store is the in-memory one, which ignores it on every method, and neither Org.Me nor Org.Membership has a tenant field to scope by. Org.Store is a read interface over the host's own tables, so the scoping is the host's; what the library owes them is that the context arrives unchanged. That is now pinned by a test, because dropping it silently is exactly what went wrong in the password reset above. Four orgs test files had no tenant coverage at all.

OrgsImpl is handed the event bus and never emits. There is no org.* event in the bus's map, so membership and role changes — setRoles grants privileges — leave no audit trail, where every comparable operation (mfa.enrolled, identity.linked, authz.revoked) does. Adding them is a change to the public event map rather than an audit fix, so it is called out here and left to a deliberate one; a host needing the trail wraps the three mutators.

setRoles on a membership already marked leftAt succeeds on the memory store. The write is unreadable — listMembers and resolveMembership skip left rows and re-adding overwrites the roles wholesale — so it is documented on the method rather than guarded, the liveness rule belonging to whichever store owns the table.

A misconfigured device-fingerprint detector was silent, not loud

deviceFingerprintDetector({ store }) — with neither compose nor authSha256 — built fine, registered fine, appeared in list(), and then returned [] for every request it ever saw. Its composer had nothing to hash with, so it answered null, and the detector skipped on null.

A detector that is switched off and a detector that finds nothing wrong are the same observation to whoever reads the decision. The rest of this file is written against exactly that: an address the guard cannot parse gets a shared bucket, a request that sends no User-Agent gets a shared bucket — "rather than going quiet", as one of the tests is named. A wiring mistake was the one case that did go quiet. It is now refused at construction with AUTH_MISCONFIGURED, the way the sibling impossible-travel detector already refuses its own bad config.

The test that covered this asserted the silence as correct.

decide() did not normalise the scores it was handed

Signal.score is documented as "0..1 ... clamped into that range on intake", and evaluate clamps every score a detector returns. decide() is the other intake — the same ladder, standalone, for re-deciding on signals a caller kept — and it clamped nothing, so the noisy-or arithmetic ran on numbers it is not defined over.

Above 1, 1 - score goes negative, and two negatives multiply back to a positive: the aggregate falls as evidence is added. Two signals that each denied on their own came out a step-up together. Below 0 is the dangerous direction — a score of -9 multiplies the remainder by ten, drives the aggregate negative, and mutes every real signal beside it into an allow.

The clamp moved into the shared aggregate, so both entry points agree. evaluate was never exposed to this, since it clamps on the way in from the detector; what this fixes is the public decide() and the two paths disagreeing about what a score means.

The Redis event bus subscribed to every channel twice

on() subscribes to a channel once per event, guarded by _subscriptions.has(event) — but the map was only written inside the subscribe promise's .then(). Two on() calls for one event in the same tick both read an empty map and both subscribed, and registering several handlers for one event at boot is the ordinary case, not an edge one.

The result: every remote message was dispatched twice to every local handler, and the second subscribe's unsubscribe overwrote the first in the map, so one channel stayed open for the life of the process with nothing able to close it. On the valkey adapter it was worse — it adds an ioredis 'message' listener per subscribe, and its own docstring states the invariant it relies on: "RedisEvents.on() subscribes at most once per channel ... so it never needs concurrent listeners on the same channel." That was the claim; this was the code. It is true now.

The subscription promise is recorded synchronously, so the guard reads what it is guarding, and unsubscribing resolves it first — which also closes a channel that was unsubscribed before its subscribe had landed.

RedisEvents and InMemoryEvents disagreed about what an emit is

InMemoryEvents.emit snapshots its handler set before dispatching, with a comment giving the reason: a handler that subscribes or unsubscribes mid-emit must not reorder the dispatch or extend it. RedisEvents._dispatchLocal iterated the live Set, and a for...of over a Set visits entries added after the cursor.

So a handler that called on() for its own event ran inside the emit that triggered it, one that re-subscribed itself never terminated, and one that unsubscribed a sibling cancelled a delivery that had already been decided. Two implementations of one bus contract, differing in whichever one an app happened to configure. RedisEvents now snapshots too.

Nothing bounded the step-up gate

flows.completeStepUp verified a TOTP or a backup code with no rate limit, no failure counter and no lockout event. MfaFacet holds no limiter of its own, so every call site has to bring one, and this one did not — a caller could ask forever and each refusal was indistinguishable from an ordinary bad guess. A TOTP is six digits and matchTotpStep accepts a drift window, which is roughly three live codes in a million: guessable in minutes at a modest request rate, by exactly the caller a second factor exists for — one holding the password and not the phone.

completePasswordReset's MFA branch has had this guard all along, keyed recovery:password:mfa:*; the primary step-up path never got one. It now consumes stepup:${identityId} once the session resolves, so the refusal names a subject and grinding one account spends only its own budget, and routes through refuseRateLimited like the other guards — AUTH_RATE_LIMITED with a retryAfter, and a lockout event an operator can act on.

A password alone could delete the second factor

mountHono registers POST /auth/mfa/totp/remove, and it took any authenticated session. A session that had only ever proved a password could therefore delete the TOTP factor that exists for exactly the case where that password is stolen — no code to guess, one request. It is the only mounted route that destroys a credential, and it was the least guarded.

It now requires the factor be proved before it may be removed: flows.checkStepUp(session, { aal: 2 }), refusing with AUTH_STEP_UP_REQUIRED and the challenge when the session has not stepped up. A stepped-up session removes it as before.

POST /auth/mfa/totp/verify was unbounded on the same router and answers { ok: false } with a 200 however often it is asked, which is a clean six-digit oracle. It now consumes the same stepup:${identityId} bucket completeStepUp uses, so switching routes does not buy a second budget.

Backup codes could be minted with no entropy at all

MfaImpl._randomBackupCode was the only randomness in the package that did not come from node:crypto. It allocated a zero-filled Uint8Array, then filled it only if typeof globalThis.crypto?.getRandomValues === 'function' — and when that guard was false it carried on with the zeros. Every byte then indexed the alphabet at 0, so all ten of an account's backup codes came out aaaaa-aaaaa: the entire MFA recovery set reduced to one guessable string, with nothing thrown and nothing logged.

The guard was there for portability the package does not need — ~/core/crypto, which mfa.ts already imports, requires node:crypto outright, so there is no runtime where this package loads and randomBytes does not. It now calls randomBytes directly and the branch is gone, so there is no longer a path where the generator can return a constant.

A backup code was single-use only in the docstring

MfaImpl.verifyBackupCode matched a live row and then called revoke unconditionally. Two verifications that both read before either wrote matched the same row and both answered true, against a class docstring that calls these codes single-use. flows.completeStepUp with method: 'backup-code' lands here, so one code bought two elevations.

This is the shape closed across eleven sites in the previous release — BackupCodesFacet.verify was the seventh of them and carries the explanation. That sweep fixed the facet and missed this second, independent copy of the same function, which is the one auth.mfa calls. It now claims the row with a CAS burn before revoking it: the loser sees AUTH_STALE_WRITE and answers false, like any other miss, and because the match is timingSafeEqual(secret, hash) the burn also stops a reader arriving after the claim.

Known: BackupCodesFacet codes cannot satisfy a step-up

Not changed in this release, because fixing it means choosing a canonical encoding and that invalidates one side's stored codes. Recording it so it is not rediscovered as a mystery.

There are two backup-code implementations, both public, both writing kind: 'recovery' rows with metadata.purpose: 'mfa-backup-code' for the same identity — and they hash incompatibly. auth.mfa.regenerateBackupCodes mints 9e9am-gkapm and stores sha256(code.toLowerCase()); BackupCodesFacet.generate mints FJQ2-GR4B and stores the hash of the uppercase, de-hyphenated, re-grouped form. Each one's verify returns false for the other's codes.

flows.completeStepUp({ method: 'backup-code' }) routes to auth.mfa.verifyBackupCode, and it is the only path from a backup code to AAL 2. So a host that wires BackupCodesFacet — which is exported for exactly that, and is the configurable one — gets codes no step-up will accept, and the user's valid code is refused as though it were wrong. Until this is settled, use one producer with its own matching verify, and do not mix them on one identity.

The otpauth QR code showed the escape instead of the issuer

buildOtpAuthUri ran encodeURIComponent over issuer and then passed the result to URLSearchParams, which percent-encodes on its own. An issuer of Acme Corp therefore left as issuer=Acme%2520Corp, and the authenticator app displayed the literal Acme%20Corp under every enrolled account. The label in the path is built by hand and encoded once, correctly, so the two disagreed — and the Key Uri Format requires the issuer parameter and the label prefix to be the same string.

Invisible on the default issuer: 'duck-auth', which has nothing to escape, so no existing test saw it. The parameter is now passed raw and URLSearchParams does the single encoding; the label is unchanged.

Maintenance and read-only mode were unreachable

OperationsImpl is the two ambient deploy switches: maintenance(true) refuses new sign-in, sign-up and refresh with AUTH_MAINTENANCE and a Retry-After while live sessions keep resolving, and readOnly(true) raises AUTH_READONLY_MODE on every mutating route for a cutover or a freeze window. It has 155 lines, two error codes, four events, a Store contract and two test suites. Nothing could reach any of it.

operations() and OperationsImpl were value-exported from no barrel and no exports subpath — only the Operations type shipped, so a host could name the state and never build one. The package did not construct one either: OperationsImpl appeared nowhere outside its own index.ts. The suites pass because they call new OperationsImpl() directly, which is the shape where a unit is proven to work and nothing is proven to use it.

The factory and the class are now exported from @gentleduck/auth/core, as AuthWebhookDeliverer already is — that is the sibling case, the other surface a host wires itself, and it was reachable. The root barrel re-exports ./core as types only, so its pinned key list is unchanged.

Two docstrings claimed assertOperationsForRoute is "run by every server adapter before dispatch" and that both switches are "reached by server adapters" through it. No adapter calls it; src/server/ does not mention operations at all, across all nine. Both now say so. The gate is still not wired into any adapter — a host that wants these switches enforced has to call assertOperationsForRoute(method, route) in its own middleware. Wiring it here is left out on purpose: it needs a per-route decision about which routes carry { maintenance: true } (sign-out and session resolution have to keep working during a window) across the fifty-eight handler factories the nine adapters export, and that is a design call rather than an audit fix.

The README's import examples did not resolve

Twelve import specifiers across seven code blocks named a subpath the package does not export or a symbol the module does not have, starting with the quickstart: createAuth was imported from @gentleduck/auth/core/config and the password provider from @gentleduck/auth/providers/password, neither of which is in the exports map, so the first example in the README failed at module resolution. Its providers array also called password({ findIdentityByEmail, passwords }), where the working shape is passwords({ hasher: new Argon2idHasher() }).

The rest: @gentleduck/auth/core/transport/dpop and the three @gentleduck/auth/captcha/* paths do not exist (DPoP ships from core/transport, the verifiers from core as authTurnstileVerifier, authHCaptchaVerifier, authRecaptchaV3Verifier); adapters/redis was shown exporting RedisSessionStore, RedisIdempotencyStore, RedisLimiter and FakeRedis, of which the first two exist nowhere and the last two live at limiters/redis and test; server/hono exports mountHono, not mount; server/grpc exports withGrpc, not authGrpcService; and createAuthClient is exported only by client/vanilla, while the React, Vue, Solid and Svelte barrels were all shown importing it — each has its own API, and the React entry contradicted the comment on the line above it.

A markdown fence type-checks in no build, which is why every one of these survived. readme-imports.test.ts now parses the README's imports and asserts each subpath is in the exports map and each symbol is exported by the module behind it, so the examples fail with the code that renames an export.

gRPC had no way to switch the drift check on

Each of the seven HTTP adapters takes a getCaller, and passing one is what turns on auth.hijack and the anomaly detectors for that request — without it the session's ip and userAgent are stamped at sign-in and never looked at again. withGrpc took { required, headerName } and nothing else. It never built a fingerprint, never passed a requestSnapshot to resolveSession, and never gave withResolvedActor an onSession, so a gRPC deployment ran with drift detection off and no hook to enable it. src/server/__tests__/request-security.test.ts covers the wiring for all seven and names grpc nowhere, which is why it stayed that way.

withGrpc now takes the same getCaller and onHijack as its siblings, and a grpcCaller reads the user agent off the call metadata. Opt-in exactly as they are: requestSecurity answers {} when no caller is supplied, so a deployment that does not ask keeps the behaviour it had — the first of the new tests pins that.

One sharp edge, pinned by a test rather than hidden: gRPC Metadata carries no address, so grpcCaller offers only a user agent. A session that recorded an ip during an HTTP sign-in reads as stripped ip drift on every later gRPC call, which the default onMissingSignal: 'soften' reports as suspicious and answers rotate. A host that can resolve the peer should pass its own getCaller; that is why the hook takes the whole call.

Smaller corrections

  • CookieTransport.Cfg.maxAgeSec said the cookie was capped by absoluteExpiresAt; it is capped by expiresAt, the sliding deadline each rotation reissues against.
  • CookieTransport.Cfg.secure now records that the companion __Host-duck-csrf cookie is Secure regardless, because the prefix requires it — so over plain http the browser drops it, keeps the session cookie, and the double-submit check fails closed on every write with nothing said.
  • CompositeTransport.extract narrows its first token instead of asserting it.
  • limiters/__tests__/memory-limiter.test.ts contained only RedisLimiter tests and is now named redis-limiter.test.ts; the memory limiter's own cases live in limiter-bounds.test.ts.
  • RedisLimiter now records that its resetAt is now + windowMs rather than the key's remaining TTL, so Retry-After over-states the wait. Reading the real one needs a pttl on RedisLike.Client, which is left as a deliberate change rather than made here.

Migration

beforeafter
const s = await auth.resolveSession(req) then if (!s)const s = await auth.resolveSession(req).orNull() then if (!s)
const u = await auth.identities.getById(id) then if (!u)const u = await auth.identities.getById(id).orNull() then if (!u)
const gone = await auth.sessions.revoke(sid) then if (!gone)const gone = await auth.sessions.revoke(sid).orNull() then if (!gone)

Appending .orNull() is the mechanical fix and preserves the old behaviour for absence exactly, while a store outage now throws where it used to be indistinguishable from a miss. .orDefault(x) and .wrap() are there when a fallback or a { data, error } pair reads better. TypeScript finds most call sites, but not if (!x) guards or x?.y chains over a value that can no longer be null — those compile and silently stop firing, so grep for them.

Also in this release

  • The public-surface test now sweeps every method on every facet of the engine and the transaction facade, failing the build by name if one answers null again. Three synchronous lookups are documented exceptions: providers.resolve and transport.extract (and the facade's providers.resolve).
  • AUTH_SESSION_NOT_FOUND and AUTH_SESSION_EXPIRED are declared but raised nowhere; they are left in place for this release rather than removed silently.

5.11.0

Minor Changes

  • 7228f81: Storage adapters: one class per backend, implementing the engine's store contracts directly, plus a .wrap() escape hatch on every adapter call.

The bridge layer is gone

@gentleduck/auth/adapters/sql used to define a second, lower-level contract — 23 operations shaped around a where/patch pair — that each dialect implemented and sqlStores then translated into the four stores the engine actually binds. Two contracts for one job: every method was written twice, and the translation layer was where the absent-row rule, the actor stamping and the patchMetadata retry lived, invisible to the dialect that produced the row.

Each adapter now implements Identities.Store, Credential.Store, Sessions.Store and Events.Store itself. What the translation layer held is either inlined where it belongs or shared as a free helper on the new base.

Removed from @gentleduck/auth/adapters/sql: SqlBridge, SqlStore, SqlStores, sqlStores, SqlIdentityStore, SqlCredentialStore, SqlSessionStore, SqlEventStore, asBridge, orNull. The entry point keeps withSqlEventLog and the JSON-column codecs, which were never part of it, and is now @gentleduck/auth/adapters/drizzle: with the bridge gone there is no dialect-agnostic SQL contract left for the old name to describe, and everything behind it is shared only by the three drizzle dialects.

Renamed, with the two statics collapsed into one:

beforeafter
DrizzlePgBridge.bridge(db) / .storage(url)new DrizzlePgAdapter(db | url | pool)
DrizzleMysqlBridge.bridge(db) / .storage(url)new DrizzleMysqlAdapter(db | url | pool)
DrizzleSqliteBridge.bridge(db) / .storage(path)new DrizzleSqliteAdapter(db | path | client)
createSqlStores(bridge)— (an adapter is already the stores)

There is no open() static: the constructor takes the connection string, the driver pool or a drizzle handle and resolves it, so new is the only way in.

MemoryIdentityStore, MemorySessionStore, MemoryCredentialStore and MemoryOrgStore are no longer exported separately: MemoryAdapter is one class on the same base, with the same four facets. memoryAdapter() and MemoryAdapter#raw are unchanged.

One base class, one contract

@gentleduck/auth's adapter layer is now two declarations. Adapter is the contract and nothing else: the four stores, the Answer<T> a call returns and the Result<T> its wrap() hands back. AdapterStore is the class every adapter extends: it takes its driver's error mapper and exposes run, the one place a failure is typed and wrap is attached.

asAdapter is gone. It checked at runtime that an adapter had all four stores — something implements Adapter.Me already proves at compile time — and used that check to launder the profile type. DrizzlePgBridge.storage<MyProfile>(db) is now new DrizzlePgAdapter(db): an adapter answers the profile shape its table actually declares, so an app whose profile is narrower than that is told so by the compiler rather than trusted at runtime.

answering(promise) is now AdapterStore.answer(promise). stamped and rowWithLinks are gone: each had a one-line body behind a name, and both are now written where they are used.

A store is plain CRUD

Identities.Store declared six optional set-based writes alongside the single-row ones — softDeleteMany, restoreMany, eraseMany, updateProfileMany, linkMany, unlinkMany — as a fast path a store could implement instead of being looped over. No adapter ever did, in any dialect, so every one of them was a branch that never ran and a compliance test that only ever skipped. They are gone, and the contract is the single-row CRUD it always was in practice.

AuthIdentities#softDeleteMany and its five siblings are unchanged: the facet loops, reports one outcome per input row in input order, and names each refusal the same way it did before.

Sessions keep theirs, and every store implements them

Sessions.Store kept three optional set-based forms — deleteAllForIdentities, deleteMany and listByIdentities — and for a while shared the identity side's problem: declared, consumed by the facet, implemented by nobody, so revokeAllForIdentities over 500 people was about a thousand round trips on Postgres where it should have been two statements. That is the one batch path with a caller that matters — "sign this whole org out" — so the answer here is the other one: every adapter implements all three.

pg and sqlite delete the set in one statement and read back the ids that matched; mysql has no RETURNING, so the ids are selected under their own lock first, the same read-then-delete _erase already does for credentials; memory makes one pass over the map instead of one per identity; redis batches the record delete, the expiry zrem and the per-identity srem into three round trips for the whole set rather than three per identity.

outcomesFromAffected is shared from core/batch — the ids a statement affected become one outcome per requested id, and an id that did not come back matched no row, which is the only thing one statement can say about it.

RedisLike.Client gains an optional mget. listByIdentity backs the active-devices screen and runs inside every revokeAllForIdentity, and it was issuing one GET per session; it now reads them all in one round trip, falling back to concurrent gets for a client without the command.

Credential.IStore.deleteByIdentities is gone. It had no implementation and, unlike the session forms, no caller either — nothing in the package ever invoked it, so it was a declaration and a compliance case and nothing else.

.wrap() — a failure as a value

Every adapter call now answers an Adapter.Answer<T>, a native promise with one extra method:

const { data, error } = await storage.identities.find({ email }).wrap();
if (error) return reply(error.code);

error is the discriminant and is always an AuthError, never unknown: a non-Error a driver throws becomes the cause of a real one rather than being handed back raw. Answer<T> extends Promise<T>, so awaiting it throws exactly as before and every existing caller — Promise.all included — is untouched.

Scope: wrap() catches what the call rejects with. An argument that throws before there is a promise still throws at the call site; a bug in the call is not a failure of the work.

error also carries the codes that call can raise, not a flat AuthError:

const { error } = await storage.sessions.update(id, patch).wrap();
//    ^? AuthError<'AUTH_SESSION_REVOKED' | Adapter.Faults> | null

Adapter.Faults is what any call can raise — the whole range of a driver's error mapper, since any statement can meet a constraint, a dead socket or a missing schema. Adapter.Me names what each method adds on top: restore can answer AUTH_GRACE_EXPIRED, patchMetadata and upsert AUTH_CREDENTIAL_NOT_FOUND, update AUTH_SESSION_REVOKED. The union is closed, so a switch on error.code is exhaustive, and comparing a read's error against a code it cannot raise no longer compiles.

AdapterStore.answer(promise) now types the failure on the await path too, as run always did, and attaches wrap to a promise of its own rather than to the caller's.

Each adapter declares its facets as their slot in Adapter.Me rather than checking an inferred object against the contract with satisfies. A satisfies only proves the body is assignable, so the type a caller saw came from the body — run's Adapter.Faults and nothing else — and the extra codes above were declared where no call site could read them. storage.identities.restore(id) now answers AuthError<'AUTH_GRACE_EXPIRED' | Adapter.Faults>, which is what this section always said it did.

A missing row raises — .orNull() when absence is an answer

No adapter call returns null any more. A read or a write that matched no row raises the code that names what was missing — AUTH_IDENTITY_NOT_FOUND, AUTH_CREDENTIAL_NOT_FOUND, AUTH_SESSION_REVOKED, AUTH_ORG_NOT_FOUND, AUTH_MEMBERSHIP_NOT_FOUND — instead of handing back a value the caller has to remember to check.

The old contract typed some calls T | null and others T, so which ones could miss was whatever each author had happened to think about. A caller that forgot the check got undefined two frames later, with nothing naming the row that was not there. Every store method is total now: Identities.Store, Credential.Store, Sessions.Store and Org.Store all answer T.

Org.Store was the last holdout. getOrg, removeMember and setRoles each answered null for a row that was not there, which is the exact shape the rest of this section removed; AUTH_ORG_NOT_FOUND and AUTH_MEMBERSHIP_NOT_FOUND are new so a membership miss cannot be reported as a missing org and send a caller off to create one that already exists. orgs.get(), orgs.removeMember() and orgs.setRoles() keep their nullable signatures — the conversion moved into the facet, where the other facets already do it.

The mysql and sqlite adapter classes also carried a public find() that answered null. It is the join pg keeps as private _find, used only from inside the class, and it is now private in all three.

Absence is still an outcome for the callers it is an outcome for — "is this address free", "is this login attached" — so Answer gains a third form alongside await and .wrap():

const identity = await storage.identities.find({ email }).orNull();
if (!identity) return reply("free");

.orNull() converts exactly the five absence codes above and re-raises everything else, so a dead socket or a constraint violation still throws rather than reading as "not found". Where a facet's own public API is legitimately nullable — auth.identities.getById, auth.sessions.getBySid — that is where the conversion now happens, once, instead of at every call site behind it.

Removed: rotateOrThrow and patchMetadataOrThrow. They existed to turn a null back into a throw, which is what the call does by itself now.

The memory adapter's ErrorMap also declares AUTH_IDENTITY_NOT_FOUND, which it raises in nine places and never named. Undeclared, wrap() would have re-labelled a plain 404 as AUTH_ADAPTER_FAILED.

Breaking. A hand-written Org.Store must raise where it returned null, and declare both codes in its ErrorMap or wrap() re-labels them AUTH_ADAPTER_FAILED. A caller that tested a store read for null now has a branch that cannot be taken: narrowing Promise<T | null> to Promise<T> leaves an existing if (!row) as dead code rather than a type error. Add .orNull() at the call sites where the miss was the point, and delete the check everywhere else — the raise carries the same fact with the row's name attached.

version moves on every write the row accepts

auth_identities.version and auth_credentials.version are what a conditional update locks against, and only three writes were maintaining them: identities.update, the credential rotate/patchMetadata pair and the family revoke. softDelete, restore, link and unlink left the number where it was on all four adapters, so a caller holding version N passed its expectedVersion check on a row that had been hidden or re-linked underneath it — the lost update the column exists to stop.

Every write that changes what a read answers now moves the version, including a repeat link that inserts no second row: the version counts writes the row accepted, not observable diffs, which keeps one rule across four adapters and errs toward a spurious AUTH_STALE_WRITE rather than a missed one.

It costs nothing on Postgres and SQLite — the bump rides the .set() that was already being sent, or replaces the trailing read via RETURNING/a writable CTE, so link and unlink send the same number of statements they did before. MySQL pays one statement on each, having neither.

runIdentityStoreCompliance pins it: every one of those writes must return a higher version than the last, and the version read before them must then be refused by update.

A batch answers the rows it touched

Every batch write now answers Me<Profile>[] — the same shape the single-row form answers, once per row that landed:

const hidden = await auth.identities.softDeleteMany(ids);
const missed = ids.filter((id) => !hidden.some((row) => row.id === id));

It used to answer a Batch.Result<T> — { outcomes, applied, failed } — one { id, ok, reason } entry per input row, with reason drawn from its own seven-value enum that a translation table mapped the thrown AuthError onto. The whole structure is gone: Batch.Result, Batch.Outcome, Batch.FailureReason, batchResult, toSoftReason, the BATCH_NOT_FOUND sentinel, outcomesFromAffected, outcomesFromRows and the core/batch module that held them.

Three things were wrong with it. The reason table was a second vocabulary for refusals the engine already names, and it was a filter as well as a map: a code it did not list was treated as a hard failure and killed the batch, so every new per-row refusal silently became batch-fatal until somebody added a line. The applied/failed counters were two derived numbers every producer had to remember to compute, that nothing in the library read. And the wrapper meant a batch was the one part of the API whose answer you could not use the way you use every other answer.

What a caller gives up is the reason a particular row was refused — a closed grace window and a missing row now both read as an absent row. Diff the input against the answer to see which; call the single-row form on one to find out why.

The hard/soft split survives, because it is not about reporting: a refusal is soft when it is an AuthError with no cause, and hard when it carries one. A soft refusal leaves its row out of the answer; a hard one throws and takes the batch with it, because a driver error leaves a Postgres transaction aborted and swallowing one would make the caller's COMMIT a silent ROLLBACK after it had been handed a list of rows that "landed". That rule is one seven-line function in core/identities, applied by the four batch writes that loop.

A store method answers the rows it wrote

The set-based store methods are no longer optional, and they answer rows rather than ids. Identities.Store.softDeleteMany and eraseMany answer Me<Profile>[], so a caller reads the new deletedAt, the bumped version and the providers array off the write instead of asking again; eraseMany answers each row as it stood immediately before it went, which is the last moment anyone can see it. Sessions.Store.deleteAllForIdentities and deleteMany answer the { id, identityId } of every session they removed.

These five were declared ?: and every shipped adapter — memory, all three dialects, redis — implemented all of them anyway, so each facet carried a one-statement fast path and a loop that nothing ever took. The per-row bookkeeping that used to be repeated in nineteen adapter call sites is gone outright rather than moved: the facet returns what the store answered. An adapter no longer imports core/batch, because there is no core/batch.

Postgres and SQLite widen a RETURNING clause they were already sending — Postgres joins the links back through the same writable CTE its single-row erase uses, SQLite reads them alongside as its erase does. MySQL, which has neither, widens the locked SELECT it already takes before the delete.

Answering the removed sessions also retires two reads. The facet emits one session.revoked per row, so it had been reading the rows before the delete to learn their names: revokeByHashes ran an N-call getByHash map and revokeAllForIdentities a whole listByIdentities. Both are gone — the delete already knew. Redis names the rows off the identity index rather than the records, so a record that no longer parses still counts as revoked.

Sessions.Store.listByIdentities is removed. Its only caller was that pre-read, and leaving a required method nothing calls is a method every custom adapter implements for nothing.

runIdentityStoreCompliance and runSessionStoreCompliance dropped the five ctx.skip() guards that went with the optionality — they could no longer fire, and a suite that reports a skip nobody can trigger reads as coverage.

Breaking. A batch method answers T[] where it answered Batch.Result<T>: what was result.outcomes.filter((o) => o.ok).map((o) => o.value) is the answer itself, result.applied is answer.length, and a refused row is absent rather than present with ok: false. Batch is no longer exported. A custom adapter must implement softDeleteMany, eraseMany, deleteAllForIdentities and deleteMany, answering rows, and may drop listByIdentities.

identities.find takes one address, not a list of spellings

Identities.Store.find is one union of three shapes, and the email arm is a string:

find(by: { id: string } | { email: string } | { providerId: string; providerSub: string }): Promise<Me<Profile>>

It used to take string | readonly string[] there, because a row written before addresses were normalised holds the bytes its client sent — so a lookup for the canonical spelling alone would lock its owner out. Six call sites each expanded the address themselves and passed the list down, with their own ?? fallback for a blank one. The expansion now happens once, inside the store, where the comparison is: toEmailList takes one address and answers every spelling to try.

That also retires an invariant that had to be checked at runtime. An empty list is no condition at all, so find({ email: [] }) would have matched the first live row; toEmailList used to throw AUTH_INVALID_PARAMETERS to stop it. One address always expands to at least one spelling, so the case cannot arise, and a blank address answers [''], which matches nothing because every dialect refuses to store a blank one.

Every mutating write on the store answers Me<Profile> for the same reason find does: softDelete, restore, erase, link and unlink hand back the row they touched rather than void, so a caller needing the new deletedAt or the providers array after a link does not read again to find out.

Breaking. find({ email: [...] }) takes the address itself. Identities.By is gone — the union is written out where it is used. toEmailList takes a string and no longer throws.

Fixes this turned up

  • link against an identity that no longer exists surfaced the raw foreign-key violation; it now raises AUTH_IDENTITY_NOT_FOUND, as every other call that matches no row does.
  • A soft delete no longer takes deletedAt/deletedBy from the caller: the window is a grace period in milliseconds and the actor is the ambient one, so two dialects can no longer disagree about when a row becomes purgeable.
  • An empty session patch is a no-op rather than a driver syntax error, and a missing session answers AUTH_SESSION_REVOKED on every backend.
  • patchMetadata on a row that is not there (or not this tenant's) answers AUTH_CREDENTIAL_NOT_FOUND rather than telling the caller to retry.

One read per adapter, and no handle to pass

Each dialect had a private read taking the client to read through, so all ten of its call sites passed this._db to say "the usual one". A read no caller runs inside a transaction now just uses the adapter's own handle — the parameter is gone from Postgres's _find and SQLite's _linksFor. The rest take it as an argument defaulting to this._db, so a transaction passes its tx and no call site can quietly read outside the transaction it is in.

MySQL reached those through this.withClient(tx), which re-runs the adapter constructor on every transactional read: a createRequire built and discarded on Postgres, a pragma foreign_keys that SQLite ignores inside a transaction anyway. It threads the handle like the other two now, so one mechanism answers this rather than two, and withClient is left as what its contract says it is — how a caller rebinds the adapter onto a transaction of their own.

The handle aliases went with it. Pg.Handle, Mysql.AnyMySql2Database and Sqlite.AnySqliteDatabase each named the type a statement runs on, and each had one or two uses left, all inside its own adapter. They spell the drizzle type directly now — SQLite's onto the Db<> already declared beside it — so no dialect exports a handle-shaped type any more.

Types that named nothing

Pg.AnyNodePgDatabase had no uses at all. OidcOP.Prompt listed the four values OIDC defines for prompt and nothing read it, because the OP does not implement the parameter — select_account appears nowhere else in the package. Removing the type does not remove a feature; it stops advertising one that was never there.

Mfa.TotpMetadata and Mfa.PasskeyMetadata described the metadata shapes the MFA facet reads. It reads them through the credential-utils predicates now — isProfileBooleanTrue(row.metadata, 'confirmed'), getProfileNumber(row.metadata, 'lastTotpStep') — with the field names pinned in the credential allow-lists, which is the shape that actually holds at runtime. The passkey one also repeated two fields Passkey.CredentialMetadata already declares.

Mfa.WebauthnChallengeStore was Passkey.ChallengeStore field for field, in a file that already imports Passkey and whose doc comment already named the passkey provider's store as the reference implementation. It is that type now.

The three email channels each declared their own ITemplateResolver, identical across all three. One Channel.IEmailTemplateResolver replaces them. Twilio and web-push keep their own, which answer { body } and { payload, ttl? } rather than a subject and an HTML body.

MySQL had three row helpers where the others had one: a locked re-read, a links read and an assembler over both. It now answers every lookup through the same single join the other two dialects use, leaving one helper — the lock a write takes before it re-reads.

On Postgres, create and erase are one statement each rather than a transaction around two. The row, its logins and the read that answers them travel as one CTE, with the links taking the id straight out of it; erase reads the logins in the same with that deletes their owner, since every arm of one sees the snapshot the statement opened on. Four round trips become one, and the row an erase answers with now carries the logins the cascade took — proven for every adapter, not just this one.

One withClient, on the adapter

A transaction was joined store by store: every store contract carried its own optional withClient, each dialect implemented four one-line delegates back to the adapter's own, and withTransaction called them one at a time. The facets come off one adapter and share its connection, so that was four answers to one question.

withClient belongs to the adapter now — Adapter.Me declares it, the three drizzle adapters already had it, and withTransaction makes the one call. The capability hook follows: MfaFacet and ApiKeysFacet are handed the bound facets instead of a driver handle to rebind a store of their own from, so Capability.withClient(stores, events) replaces (client, events) and neither can answer null any more.

Hand the engine the adapter — stores: adapter — and transactions work. A bag assembled facet by facet (redis sessions beside a drizzle identities store, say) has no withClient, and withTransaction throws AUTH_MISCONFIGURED saying so rather than leaving a write outside the caller's transaction. createTest takes the same shape: one stores override in place of four per-facet ones.

Fewer statements per call

What a store call costs on a real database is mostly the number of statements it sends: each one is a round trip the caller waits through. Four shapes sent more than they had to, on every dialect that had them.

link read the identity to check it was there, inserted the login, then read the identity again to answer. The first read is gone — the insert takes its row from auth_identities, so an identity that is gone or hidden writes nothing and the read that answers raises on its own. MySQL also held a locked read and a second read for "does this identity already hold this provider", both inside a transaction; the repeat is settled in the same where now. Three statements to two on Postgres and SQLite, six to two on MySQL, and linking to a soft-deleted identity no longer writes a row on MySQL that the answer then hides.

patchMetadata read the row, merged in JS, wrote against the version it had read and retried once when a concurrent write took that version first. It is one update now — metadata || $patch::jsonb on Postgres, json_set on SQLite and MySQL — so the merge happens under the row's own lock and there is no window to lose. Not json_patch/json_merge_patch: those are RFC 7396, where a null value removes the key rather than storing it, which is not what the object spread the memory adapter runs does.

MySQL took a select … for update before update, softDelete, rotate, revoke and patchMetadata to learn whether the row was there at the version expected. The write takes the same lock and affectedRows answers the same question, so the probe is gone from all five, and restore no longer reads the row twice. erase keeps its locked read: it has to answer with the row it removed, so that read comes before the delete either way.

The memory adapter's create checked the address, the handle and each login in a pass of its own; it is one scan now, which is also the one place the three unique indexes every dialect carries are written down together.

One fix fell out of it: patchMetadata could raise AUTH_STALE_WRITE after losing the version twice, a code its own fault union does not name and a retry its caller could not act on. There is no version to lose now, so the only failure left is the AUTH_CREDENTIAL_NOT_FOUND the contract declares.

bun run bench:adapters times all 26 store calls on all four adapters. The before and after tables, per call, are in src/adapters/README.md.

An identity store answers one read. findById, findByEmail and findByProviderSub are find(by), named with { id }, { email } or { providerId, providerSub } — three names for one join, one live filter and one order, where only the condition ever differed. auth.identities.getById and its siblings are unchanged; it is stores that change shape, so stores.identities.findById(id) is now stores.identities.find({ id }).

The where clauses that scope a read to a tenant were each a mutable array and a pair of ifs; they are one and(...) with a shared inTenant filter that drops out when the caller named no tenant.

MySQL read an identity by address 50x slower than by any other key. The where matched lower(profile ->> '$.email') while the unique index lives on the email_norm column generated from that expression, and MySQL will not use a generated-column index when the comparison carries the connection's collation - so every lookup scanned the table. It matches the stored column now: 24 ms to 0.28 ms over 50k rows. No test could see it, since every assertion passed either way on a table of ten rows.

bun run bench:adapters is what found it: the same 50k identities and 100k logins on all four adapters, timed at the adapter boundary. The numbers, the plans behind them and the method are in src/adapters/README.md.

One way a driver failure is named

Each dialect had its own translator: the same five makers, the same socket-code list and its own four-deep cause-chain walker, restated three times, with a Make that took one argument in two of them and two in the third.

There is now one reader — signalOf — and one resolver, and each dialect is only its own table of what its driver says: the text it names the refused index in, the code or errno it gives, the family that code belongs to, read in that order. 256 lines became 189, and nothing an adapter answers changed.

AuthError itself no longer declares each code's HTTP status twice — once as a literal on the union member and once in the status table that is the only one ever read — and the class is free of type assertions: the origin argument is narrowed by a predicate, and toJSON scrubs through a function that returns a record rather than one that is cast to it.

The three dialects now refuse the same row

A schema audit across auth_identities, auth_identity_providers, auth_credentials and auth_sessions found the declarations in parity — 37 identically named indexes and checks, 18 of them checks, matched by the generated e2e DDL — while what those declarations meant diverged per dialect.

MySQL compared opaque keys case- and accent-insensitively. Nothing set a collation, so id, identity_id, tenant_id, secret, csrf_hash, provider_id and provider_sub inherited the server default — utf8mb4_0900_ai_ci on MySQL 8. pg and sqlite compare all seven byte-for-byte, so the same lookup answered differently per backend: findByHashedSecret matched a secret whose case it was never given, two base64url passkey credential ids differing only in case collided, and uq_auth_identity_providers_sub refused a sub that merely case-differed from one already stored. They are now ascii_bin, or utf8mb4_bin where the value is the app's to choose rather than this library's.

A passkey could register on two dialects and fail on the third. secret was sized 512 for Argon2id PHC strings, but the passkey provider writes credential.id verbatim and WebAuthn allows that up to 1023 bytes — about 1364 base64url characters. pg and sqlite type the column text. It is now 1400 ASCII characters, which keeps auth_credentials_kind_secret inside InnoDB's 3072-byte key limit.

A second password is a write error rather than a second way in. setPassword deletes the previous row before writing the next, so one row per identity was an invariant held by hand and by nothing else. It is now a unique index — partial on pg and sqlite, a generated-column carrier on MySQL, which has no partial index — and tenant-scoped, because that delete is tenant-scoped and two tenants may each hold one.

Retention has an index and a profile has one width. deleted_at was unindexed on all three, so sweeping soft-deleted identities scanned the table; it is now indexed, partially where the dialect allows. email and username are capped at 320 and 191 on all three, the widths MySQL's generated norm columns already imposed and the other two silently did not. And chk_auth_sessions_id_length now reads char_length on MySQL, where length counts bytes — the one check of the three that asked a different question.

Deliberately unchanged: (kind, secret) stays non-unique, because findByHashedSecret is specified to answer the freshest live row and fall back to a revoked one, which needs both to exist at once; and pg keeps uuid where MySQL and sqlite take a string, which ComplianceIds already exists to absorb.

A credential row has a shape that is safe to hand out

Credential.Me carries secret, and every store read returns it — findById, listByIdentity, upsert, revoke, delete and the rest. The facets that ship today each hand-build a DTO, so nothing leaks, but that is a convention held at every call site rather than anything the type enforces, and the field is worse than its own docblock claimed: it read "never plaintext" while kind: 'totp' stores the base32 seed exactly that way, so one res.json(row) from a new facet or a plugin is a factor a caller can replay.

Credential.Public (Omit<Me, 'secret'>) and toPublicCredential give that convention a name, and the docblock now says what the column actually holds.

Identities.ExportBlob already hand-wrote Omit<Credential.Me, 'secret'> and exportAll already destructured the secret off row by row; both now say Credential.Public and toPublicCredential instead.

core/credentials only holds credential helpers now

Six of its fourteen exports read no credential. getProfileString, getProfileNumber, isProfileBooleanTrue, isProfileBooleanFalse, isFiniteNumber and isExpiredAt take an unknown, which is why the transport, idempotency and sessions modules were all importing a credentials module to check a JWT key's notAfter or an idempotency entry's expiry. They move to core/predicates. isSoftDeleted reads an identity row, and moves to core/identities beside the type it tests.

deleteCredentialsByPurpose is gone. The store contract grew deleteByKindAndPurpose, which does the same job in one round trip, but the helper was never retired and usage went the wrong way: five call sites on the helper's list-then-delete-each loop against one on the store method. All six now call the store, so regenerating backup codes is one statement rather than a list plus a delete per code.

None of these were exported from the package, so nothing downstream moves.

The purposes table is now the only spelling of a purpose

RECOVERY_PURPOSES existed but half the codebase went around it: eleven bare 'signup-flow' / 'password-reset' / 'email-verification' / 'trusted-device' literals sat in the signup, reset, verification and remember-me paths, and backup-codes.ts kept a BACKUP_CODE_PURPOSE alias that pointed at nothing else. A purpose is the discriminator that decides which of seven token families a delete touches, so a typo in one of those literals writes a row no read path can find. All eleven now read the constant and the alias is inlined.

Both constants move to credentials.constants.ts, matching identities and sessions. core also exported AUTH_CREDENTIAL_KINDS under export type, which hid the runtime array from anyone importing it; it is a value export now, and RECOVERY_PURPOSES is exported beside it.

oauth access tokens are no longer stored

kind: 'oauth' hashed the refresh token into secret and then wrote the access token into metadata.accessToken in the clear. metadata is a Record<string, unknown>, so Omit<Me, 'secret'> could not see it, and exportAll reads every kind — meaning the GDPR Article 20 archive a user downloads carried live third-party bearer tokens.

Nothing needed the stored copy. authRefreshoauthToken already returns the fresh token to its caller in hand, and the only reader was projectAccessToken, which had no production callers. So signin and both rotation branches stop writing it, parseFamilyMetadata drops the field rather than carrying it forward (a rotation purges a row written by an older version), and projectAccessToken is gone.

Because a row that never rotates would otherwise keep leaking on export, toPublicCredential also redacts SECRET_METADATA_KEYS from metadata. The regression test asserts on the serialised export, which is where this surfaced rather than anywhere the type system could reach.

The e2e tier was never actually running

Three separate faults kept 31 files and 693 tests from proving anything, each of which hid the next.

The CI e2e job ran --filter=@gentleduck/iam only, so @gentleduck/auth's suites — real Postgres, MySQL and Valkey, the three backends with no in-process substitute — ran in no job on any merge. That is the exact gap the job was added to close, closed for one package and not the other. It now filters both, pre-pulls mysql:8.4, and sets DUCKAUTH_E2E_REQUIRE=1.

Locally the tier skipped itself and exited 0. That was deliberate for a machine without docker, but it also swallowed the case where docker answers and the backends then fail to come up — a prerequisite that is present and broken, not absent. setup() now only skips when docker is unavailable; a provisioning failure throws.

What that was hiding: the MySQL schema no longer applied. password_key was a STORED generated column reading identity_id, and MySQL refuses a cascading foreign key on a column a stored column depends on (error 1215), so every run died at the ALTER and skipped all 693 tests behind a green exit code. The column is VIRTUAL now, which MySQL accepts, still indexes for the unique constraint, and still cascades.

With the tier running, two Postgres failures surfaced. The credential ordering test built three password rows for one identity, a state uq_auth_credentials_password forbids and PasswordsProvider.set cannot produce — it deletes before it upserts. It now uses api-key, one of the kinds that may legitimately have several, which is the only case that ordering ever decides. And the constraint-coverage test named four rules the schema declares that nothing exercised: both password unique indexes and the two identity length checks. Each has a violation case now, so each is proven to answer a typed error rather than a raw driver fault.

findByProviderSub is gone from the credential store

Credential.Store.findByProviderSub was declared on the public contract and implemented by all four adapters, and nothing called it. The identities store has its own provider-sub lookup, which is what the oauth provider actually uses; this was the credential-side twin, left behind.

It was not free. Postgres and SQLite each carried a partial expression index over metadata->>'provider' and metadata->>'sub' to serve it, and MySQL — which cannot index a JSON path — carried two STORED generated columns, oauth_provider and oauth_sub, plus an index over them, plus the select() exclusion those columns forced on every credential read. All of it existed for a method with no callers. The method, the three indexes, the two generated columns and the exclusion are removed together.

A read that answered in the wrong order

Sweeping the adapters one at a time turned up three more places where a store answered differently from its peers, all of them in a read's order rather than its contents, which is why every existing case passed.

credentials.listByIdentity is documented newest-first, created_at descending with the id breaking a same-millisecond tie, and callers lean on it: passwords rotates "the first live row" and mfa.confirm takes the first unconfirmed enrollment. The memory store did no sorting at all and answered in insertion order — exactly reversed. A user who abandons one TOTP QR and scans a second one has two unconfirmed rows, and memory confirmed against the one they walked away from. It sorts now, and mints authUuidV7 ids like the dialects do, so the tie-break orders by mint time rather than by a random token.

updatedAt moved on link and unlink in every dialect, through the column's own $onUpdate, and stood still in memory. An identity whose logins had been rewired still reported as untouched since signup.

On SQLite both timestamp defaults were unixepoch() * 1000 — whole seconds widened to milliseconds, not milliseconds. findByHashedSecret orders on created_at and promises the freshest live row, so re-issuing a key inside the same second made the choice between the old row and the new one a coin toss. The default is unixepoch('subsec') now, which needs SQLite 3.42. The generated DDL is regenerated to match.

All three are covered in the shared compliance matrix rather than in one adapter's own tests, so every store answers to them.

Nothing was found in the Redis session store or in the MySQL adapter: both were read end to end. Left as observations rather than changes — SQLite's erase reads the links and deletes in two statements with no transaction around them, its pragma foreign_keys is not awaited on an async driver, and MySQL's create answers with providers in added_at order where the others answer in the order they were given.

Three places the stores disagreed with each other

An audit of the four adapters against one another turned up behaviour the compliance matrix never asked about, so each store was free to answer differently. The matrix now covers all three, and every store answers the same.

sessions.create on an id that is already stored was an overwrite in memory and a unique violation everywhere else. The id is the caller's token hash, so a repeat is a collision: taking it as an update hands whoever still holds the first token a session it never opened, at whatever AAL the second call asked for. The memory store refuses it now. Redis and Valkey were already refusing it — SET … NX is there for exactly this — but reported it as AUTH_SESSION_REVOKED, which tells a caller its session is gone rather than that the id is taken; they raise AUTH_ALREADY_EXISTS, the code every dialect's unique violation maps to.

identities.restore on a row that was never hidden answered with the row on all three dialects and raised AUTH_GRACE_EXPIRED in memory, which tells a caller retrying a restore that the account is past saving when it is simply already live. The memory store's test was !deletedAtMs, so an epoch deletedAt read as live too. It now hands back the live row, as the dialects do.

credentials.revokeFamily stamped updatedBy on the dialects and left it alone in memory. A family revocation is the breach response, so "who pulled the trigger" is the audit question it exists to answer; memory stamps it now.

Still open, and deliberately not changed here: revoke on an already-revoked credential is a no-op in memory but rewrites revokedAt and bumps the version on all three dialects, because _write has no isNull(revokedAt) guard where revokeFamily does. The dialects are the ones moving the revocation timestamp, so the fix belongs on that side, not in memory. Relatedly, rotate, patchMetadata and revoke leave updatedBy naming whoever created the row rather than whoever performed the write — uniform across all four stores, so it is a contract question rather than a divergence.

credentials.upsert is credentials.create

It never upserted. All four adapters issued a plain insert with no conflict clause, and CreateInput carries no id or unique key to match an existing row on, so every call appended a credential. The name promised the one behaviour callers would reach for it to get: passwords.setPassword works around its absence by deleting the previous row first, in a comment that says the contract has no single-call replace.

Credential.Store.upsert is now create, matching Identity.Store.create and Session.Store.create; Credential.UpsertInput is CreateInput, matching Identity.CreateInput and Session.CreateInput; and the toCredentialUpsert input builder is toCredentialCreate. Behaviour is unchanged — both tiers pass at their existing counts.

The consent store's upsert in @gentleduck/auth/oidc keeps its name: it issues ON CONFLICT/ON DUPLICATE KEY and genuinely is one.

Credential metadata exports by allow-list, not deny-list

toPublicCredential used to strip a named list of secret-bearing metadata keys. That fails open: a key that turns out to hold a secret leaks from the GDPR export until someone remembers to name it, which is exactly how the oauth access token got out in the first place.

PUBLIC_METADATA_KEYS now declares, per kind, the metadata keys that may leave the engine, and everything else is dropped. Adding a kind is a compile error until it has an entry, and adding a key that is not listed simply does not export. The trade is deliberate: operator-written metadata — the opts.metadata merged into a trusted-device row, and the operator fields parseFamilyMetadata preserves on an oauth row — no longer appears in the export, because the engine cannot vouch for what an operator put there.

Verified by mutation: restoring the pass-through fails four tests, including the export regression that asserts on the serialised archive.

Doc comments say what the code does

A sweep over every module in the package: core, providers, adapters, server, client, channels, cli, limiters, oidc, openapi, i18n, telemetry and test. Comments that restated the signature they sat above are gone, multi-line blocks are compressed to the clause that is not derivable from the code, and SECURITY / WARN / NOTE invariants that were buried mid-paragraph now open their own line so they survive being skimmed.

Several comments named things that do not exist, which is the part a reader cannot catch on their own:

  • SessionsFacet, AuthMemoryAdapter, AuthNoopLimiter and Limiter.IResult were all referenced by docs; the real names are SessionsImpl, MemoryAdapter, NoopLimiter and Limiter.Result.
  • Route and import examples were written /AUTH/signin and @gentleduck/AUTH/client/react. The client's own default base URL is /auth and the package is @gentleduck/auth, so an example copied out of a doc comment did not resolve. Eighteen such lines are corrected.
  • The delete-account confirmation path documented /AUTH/delete-account against a real default of /auth/delete-account.
  • Comments named error codes AUTH/MISCONFIGURED, AUTH/MAINTENANCE, AUTH/READONLY_MODE, AUTH/PROVIDER_FAILED and AUTH/oauth/REUSE_DETECTED. Every code in errors.codes.ts is spelled AUTH_*, so none of those matched anything a reader could grep for.

Every {@link} in the package now resolves: a target's first segment is lexically visible in the file it is written in, same-file members are qualified with their owning namespace, and a target that was out of scope is a plain code span rather than an import added only to satisfy a doc.

A hidden identity takes no write

find refused a soft-deleted row from the start, but the write paths did not, and a caller reaching one only needs an id and a version it read before the delete landed. link, unlink and update now gate on isNull(deletedAt) on every dialect, the memory store included, which had been the loosest of the four: its link and unlink read straight out of the map and never looked at deletedAt at all.

update is the one that mattered most. softDelete clears emailVerified precisely so that a restore cannot hand back a claim nobody re-proved, and an update landing on a hidden row put it straight back.

Both wrote the link table first and only then ran the gated version bump, on separate connections. A softDelete arriving between the two left the write half-applied while the caller was told it had failed — for link, the provider sub stayed claimed by a hidden row no other identity could take it from; for unlink, the login was detached and the caller told it was not. Both now run in one transaction on pg, mysql and sqlite.

A soft delete becomes a delete

Identities.Store gains gc(now), alongside the gc(now) sessions already had. Nothing purged an identity whose grace window had closed, so a deleted account was hidden forever rather than deleted: unreachable, unrestorable, and still holding its email, username and provider subs. The caller schedules it, under a leader lock in a distributed deployment, and reaches it as identities.gc().

This adds a method to Identities.Store, so a custom adapter must implement it.

A whole-store cast, removed

forProfile took an identities store and returned it as a store of a narrower Profile — a function whose entire body was as, wrapping every method mysql and sqlite defined. Its own comment admitted the problem: it narrowed a whole store without checking anything. Both dialects now declare the store the way pg always did, and the narrowing happens where a row is built rather than over an entire API.

The narrowing itself cannot go away: a drizzle table is a module singleton whose $type is the base profile, so a row read back is always the supertype of the Profile the adapter was given. It is now written at each of the eleven points a row is constructed instead of behind one helper; three of those build an empty link list, which TypeScript infers as never[] and so needs its element type named as well.

lockRow, _eraseIdentities and _hideIdentities had one caller each and are now written at the call site, so eraseMany and softDeleteMany read the way erase and softDelete next to them already did.

identities.merge is gone

It re-pointed a duplicate's credentials, sessions and provider links onto a survivor and erased the duplicate. Nothing in the engine ever called it: no flow reached it, and it existed for an admin or import job to call by hand.

It only ever moved the auth tables. An app deduplicating two accounts has to move its own rows — orders, posts, whatever is keyed by identity id — so it is writing that transaction anyway, and withClient(tx) lets it do the auth half inside the same one. A method every adapter had to implement, that erases a row, that the library never called, and that did half the job is not worth its contract slot.

link stays and is unaffected: it is the before case, a signed-in account adding a provider. What goes is the after case.

Breaking. Identities.Store.merge, identities.merge() and the identity.merged event are removed.

The unit tier is 3321 tests and the e2e tier 739: three new contract cases pin the soft-delete gate, the two atomicity fixes and gc across all four stores — memory and sqlite in the unit tier, pg and mysql in e2e — three more pin the orgs store raising and surviving wrap(), three cover the sqlite adapter on an async driver, two guard the pg and mysql fixtures against losing their second connection, and the merge cases are gone with the method.

The sqlite adapter had never run on an async driver

_atomic branches on the driver. A sync one is bracketed by hand on its single connection; an async one goes through the driver's own transaction. Every sqlite suite built a sync driver — bun:sqlite under Bun, better-sqlite3 or node:sqlite under Node — so the async branch ran nowhere, and libsql and Turso go down it.

It was worse than an untested branch. A sync driver's _atomic calls run(this._db), so the tx a write is handed is the adapter's own handle, and a private read threaded with that handle cannot be told apart from one that ignores it. Dropping the handle from _links and reading on this._db passed all 3262 tests. The equivalent edit on a dialect whose transaction really is a separate connection fails loudly — 5 e2e tests for Postgres's _linkedTo, 47 for MySQL's _find — which is how a real transaction behaves.

async-driver.test.ts builds the shape a remote driver has, where the transaction holds its own connection: two connections over one WAL file, the handle answering async so _atomic takes the driver's transaction, and that transaction handed a handle on the second connection. A read on the adapter's own handle then sees the snapshot from before the transaction opened. link and unlink are pinned there, and the same dropped-handle edit now fails both — unlink returning the two logins the row had before the write rather than the one it has after. A third case asserts the fixture's two handles really are separate connections, so the other two cannot go quietly toothless.

Postgres and MySQL never had the branch, being async throughout, and their fixtures were already multi-connection: new Pool(...) for one, and for the other new DrizzleMysqlAdapter(url) building its handle with createPool. But nothing said so, and a max: 1 or connectionLimit: 1 would have cost both suites the ability to tell a read that used its transaction handle from one that ignored it, while still passing. Each now carries the same guard: inside a transaction it compares what the transaction sees against what the adapter's own handle sees, and fails if they agree. Set either pool to one connection and the guard reports it in about five seconds, naming the cause, rather than blocking until the suite times out.

There was no live instance of the bug: no adapter reads this._db inside a transaction callback.

A write count only one MySQL driver answers

The OIDC OP stores gate single use on how many rows a write touched: codes.consume deletes and refreshTokens.consume stamps consumed_at, and each returns the row only when exactly one row moved. That is the whole of one-time codes and refresh rotation on MySQL, which has no RETURNING to settle it the way pg and sqlite do.

The count was read by a helper that understood one shape: [ResultSetHeader], which is what mysql2 answers. The same file's garbage collector carried a second, private copy of the helper that also understood { rowsAffected } — what the serverless MySQL drivers answer — so the two helpers disagreed about what a write had done, in one file, and the factory's parameter type admits both drivers. On one of the drivers the store helper could not read, every consume would find nothing written and return null: the row is deleted or marked, and the caller is told the code was no good. An authorization code that is spent and refused at the same time, and a refresh token that rotates into a dead end.

The two are now one helper, the GC's more capable one, reading either shape and narrowing at runtime rather than asserting a type. mysql-driver-shape.test.ts pins it by handing codes.consume a stub database that answers in each driver's shape; put the old helper back and the serverless case fails on its own.

The rule each dialect keeps differently was the one nothing tested

An identity may hold one password globally and one per tenant. Postgres and sqlite each spend two partial indexes on it. MySQL can do neither, so it keeps a generated password_key — case when kind = 'password' then concat(identity_id, ':', coalesce(tenant_id, '')) end — and one unique index over it, the coalesce being what stops a global row keying NULL and slipping past an index that lets NULLs repeat.

pg tested both of its indexes and sqlite tested both of its scopes. MySQL, the only one whose enforcement is a hand-rolled expression, tested none of it. It now answers the same four questions: a second global password is refused, a second within one tenant is refused, the global scope and two tenants stay apart, and a non-password kind is not constrained at all.

The OIDC garbage collectors had the same shape of gap. All three return how many rows they pruned, sqlite's was the only one a test had ever called, and the counts are exactly where the dialects differ — pg and sqlite count RETURNING rows, MySQL reads the driver's envelope. pg and MySQL now run the prune case too, all three off one fixture in the shared compliance module rather than sqlite's private copy of it.

The contract was not reachable from outside the package

Adapter.Me is what an adapter must be, and AdapterStore is what makes one: Adapter.Wrapped<S> maps every store method to an Answer, which carries wrap() and orNull(), and AdapterStore.answer is the only thing that builds one — including the re-labelling that keeps a code the mapper never declared from being passed off as one it did. Both lived in src/adapters/adapter.ts, and no entry in the exports map led there.

The effect was visible in the shipped types: dist/adapters/drizzle/pg/index.d.ts says DrizzlePgAdapter ... extends AdapterStore<SqlFault> implements Adapter.Me<Profile>, with both names imported from an internal chunk that consumers have no path to. A store written outside this package could therefore not be declared as an adapter, and could not answer like one without rebuilding Answer by hand, the security-relevant part included. This is the same failure the root barrel already had and fixed — you could call a method but not write down the type of what came back — except here it is what the engine takes rather than what it returns.

There is now an @gentleduck/auth/adapters entry exporting exactly those two names, beside the per-dialect entries that were already there. Nothing moved and nothing else was added to it: the dialect adapters keep their own entries, so the new one pulls in no driver. adapter.test.ts pins both halves — the type aliases stop compiling if Adapter leaves the entry, and the runtime key check fails if AdapterStore does.

Three tenant-scoped writes that no test held to it

Every method on the credential store takes a TenantContext, and the compliance matrix already said why that matters: a context taken and ignored is worse than one that is absent, because the caller believes it is scoped. It proved the point for findById, findByHashedSecret, listByIdentity, rotate, revoke, delete and revokeFamily.

It did not prove it for patchMetadata, deleteByKind or deleteByKindAndPurpose. All four adapters do scope them — the SQL dialects through _write and inTenant, memory through _inTenant — but nothing held them there. Dropping the tenant from all three on the sqlite adapter left the unit tier green at 3283 and tsc clean: a cross-tenant metadata rewrite, which on an api-key row means its scope, and two cross-tenant bulk deletes, all shipping unnoticed.

The three cases are in the shared matrix, so they run on every store rather than the one that happened to be audited. Each is written so it cannot pass by doing nothing: the sweeps assert the id they removed, which a sweep reaching across tenants and a sweep reaching nothing both fail. The same mutation now fails exactly those three.

Identity rows take no tenant context at all, and both session methods that do were already covered, so this closes the set.

The widest delete in the module had no case for an empty list

Four store methods take a list of ids rather than one: softDeleteMany and eraseMany on identities, deleteAllForIdentities and deleteMany on sessions. On the SQL dialects each reaches its table through inArray(col, ids) and nothing else — no tenant, no owner, no second predicate narrowing what the statement can touch. The list is the where clause.

drizzle renders inArray(col, []) as false, so an empty list matches no rows. That is the right answer and it is the one shipping. But it is a detail of a third-party implementation that the two widest deletes in the module depend on entirely, and nothing here observed it. The spelling a reader adding a guard would reach for first, ids.length === 0 ? undefined : inArray(col, ids), is a where with no condition at all — which is every row in the table. And a caller holding no ids is ordinary: a sweep with nothing to sweep. The gap between no-op and wipe is one refactor wide.

No guard was added. drizzle is already correct, and repeating the check at each call site would only duplicate it less reliably. What was missing was a test, and there was none: no case in the matrix had ever passed an empty list to any of the four.

Two now do, one per store, and each asserts both halves — the bulk call answers with an empty list, and a row created beforehand is still readable afterwards. The undefined spelling above, applied to sqlite's identity erase and session delete, fails exactly those two cases and nothing else.

With these, every method on all three store contracts — 11 on identities, 11 on credentials, 9 on sessions — has at least one case in the shared matrix.

Three of the four adapters answered their table instead of their contract

Credential.Me and Sessions.Me declare no updatedAt. The tables carry one, and pg, MySQL and sqlite all read those two tables with a bare select() — so every credential and every session came back with a column the row type says is not there. MySQL's credential rows carried a second: password_key, the generated column that stands in for the partial index pg and sqlite get.

The MySQL case is the one that shows it was never intended. Its projections open with "the row contract: each table minus its generated columns, which are index carriers and not fields", and the line under that comment strips email_norm and username_norm from identities. The next line, for credentials, took every column — including the generated one the comment is about.

Nothing here leaks a secret, and that is worth stating plainly because it is the first question this invites. secret never reaches an application: toPublicCredential drops it and allowlists the metadata by kind, the GDPR export is the only path that hands a credential out, no event payload carries one, and the server adapters never touch the credential store at all. What escaped is a timestamp and an index carrier whose two halves, identity_id and tenant_id, are already fields of the row.

The consequence is divergence rather than disclosure. The memory adapter returns exactly the contract, and it is the adapter the whole unit tier runs against, so the shape was right everywhere it was ever looked at and wrong on all three backends anyone deploys. It surfaces where keys are enumerated rather than read: the redis event bus revives every key in DATE_KEYS as a Date, updatedAt among them, so a session handed to a remote subscriber carries a field on pg, MySQL and sqlite that it never carries on memory.

Each dialect now projects explicitly, the way MySQL already did for identities, and the *Row types in pg.types.ts, mysql.types.ts and sqlite.types.ts are Omited to match rather than describing the raw table.

The reason no test saw it is that expectFieldTypes only ever iterates the fields its own spec names. It is built to catch a declared field of the wrong shape — a Date that came back a string — and an undeclared field is not something it can look at. The guard was one-directional.

expectExactKeys is the other direction, and expectRow runs both, so the 18 read paths in the compliance matrix that already asserted field types now also assert the exact key set: no column that is not on the contract, and none of the contract's missing. The key lists are Object.keys over a satisfies Record<keyof T, true> literal, so a field added to a row type stops compiling until it is listed there, and a name that is not on the type is rejected outright.

Both halves are mutation-checked. Putting sqlite's getByHash back to a bare select() fails one unit test, getByHash keys: expected 15 to deeply equal 14. Putting MySQL's credential projection back to every column fails one e2e test against real MySQL, create keys: expected 13 to deeply equal 11 — the two columns named above. Counts are unchanged at 3295 and 723 because these strengthened cases that already existed rather than adding new ones.

Left alone, deliberately. session.created and session.rotated carry the whole Sessions.Me, so csrfHash rides out to audit sinks and webhook endpoints, while ExportBlob strips that exact field from its sessions. It is not forgeable — verification hashes the submitted token and compares, so a leaked digest buys an attacker a sha-256 preimage and nothing else — but the two paths disagree about whether the field may leave, and picking which one is right changes a published event payload.

Sessions.Store.update(id, patch) types its patch as Partial<Me>, and Me includes id. That id is not a surrogate key: it is the hash of the token in the caller's cookie, which is why create refuses a second write to one rather than treating it as an update.

The memory store pins it — "id is pinned because the key is the sid hash the cookie carries" — and so does the redis store, whose own line reads "id is pinned to the key the row lives under, and undefined means 'leave this alone', as in the memory and SQL stores". That last clause was the bug. pg, MySQL and sqlite passed the patch into set() whole, so a patch naming id rewrote the primary key.

Same call, two behaviours, decided by which backend is configured. On memory, redis and valkey it is a no-op. On the three SQL dialects the row moves to a hash nobody was ever issued: the cookie the caller holds now reaches nothing, which reads as a silent sign-out, and the session answers to whatever hash the patch supplied.

It was not reachable through the engine, which is worth stating: the only two internal callers pass fixed literals, { aal, fresh } on step-up and { expiresAt, fresh } on refresh. What makes it worth fixing anyway is that Sessions.Store is a published contract — an application holding the store directly, which the @gentleduck/auth/adapters entry exists to support, gets whichever of the two behaviours its database happens to give it.

All three dialects now drop id before building the SET clause. A patch naming it is ignored rather than refused, because update is the refresh path and a caller spreading a row it just read must not start failing. The compliance case asserts all of it — the answer keeps the original id, the rest of the patch still applies, the original hash still resolves and the supplied one does not — so neither a move nor a refusal passes. Reverting the pin fails it on sqlite in the unit tier and on real Postgres in e2e.

The one repoint that mattered was pinned only where it was hard

update may legitimately change identityId; that is how a session moves between accounts. The SQL stores get the consequences for free, because everything downstream filters the column. Redis does not: it keeps a set per identity and a scored expiry member carrying the identity, and it moves the session between them by hand, under a note that a session left behind in the old owner's index is one "sign out everywhere" cannot reach.

That work was pinned by redis's own tests and nowhere else, so the contract never said a repointed session follows its new owner — only one implementation's test suite did. It is in the shared matrix now: after the repoint the session is listed under the new identity and not the old, and deleteAllForIdentity(new) reaches it. Deleting redis's index move fails the case on redis and valkey, and the four adapters that had it structurally keep it deliberately.

Two of the three schemas could say anything without a test noticing

The e2e suites do not create their tables from the drizzle schema. They apply a committed .sql that bun run e2e:schema generates from it, and nothing regenerates that file: no CI step runs the script, and no CI step checks the output is current. The only thing holding the schema and the database together is a developer remembering a sentence in a comment.

sqlite had a guard, and its docblock states the problem exactly — "a constraint could be deleted from the schema and the whole matrix would go on passing against a file nobody regenerated". It also names the sharper half: drizzle evaluates a table's extra-config block lazily, so without a test that forces it, nothing runs the code declaring those checks, indexes and foreign keys at all.

Neither pg nor MySQL had one, which is the wrong two to leave out. sqlite's suites run in the unit tier and give an answer in seconds; pg and MySQL run only behind docker, in the tier that gets run least.

It is demonstrable rather than theoretical. Adding check(kind <> 'api-key') to the MySQL schema — a constraint that would reject every api-key row in the table — left all 731 e2e tests passing against a real MySQL, because the constraint never reached the database and nothing compared the two. The identical edit to the sqlite schema fails two tests immediately.

schema-drift.ts carries the shared half and there is now a schema test per dialect. They need no database, so pg and MySQL drift is caught in the unit tier alongside sqlite's rather than never. Both directions are mutation-checked: a constraint added to the MySQL schema without regenerating now fails with ``chk_zz_demo_never_applied is declared but never reached the generated DDL, and a check deleted from the pg schema while the DDL keeps it fails the opposite assertion.

The DDL is not stale today — regenerating all six files produced byte-identical output. What was missing was anything that keeps it that way.

Checked and clean, recorded so the next pass skips them. The version counter moves identically on every mutator across memory and the SQL dialects — create, rotate, patchMetadata, an empty patchMetadata, link, a repeated link, unlink, an absent unlink, softDelete and restore all agree — with the single exception of revoking an already-revoked credential, which is the divergence already open above. And MySQL's asciiKey/binKey really are binary collations, ascii_bin and utf8mb4_bin, so the case- and accent-folding hazard their own SECURITY note describes is genuinely closed: a secret or a session id compares byte-for-byte there exactly as it does on pg and sqlite.

The store every unit test runs on enforced none of its table's rules

auth_credentials is the most constrained table in the schema: a kind from a fixed list, a secret that is not blank, a tenant that is not the empty string, an expiry no earlier than the row it belongs to, and one password per identity per tenant. The memory adapter has no schema, and it checked none of them.

Five writes were accepted by memory and refused by all three SQL dialects: a second password for one identity, a blank secret, an empty-string tenant, an expiry preceding createdAt, and a kind the contract does not name. Every unit test in the package runs on memory, so a write reaching a database for the first time in production is where these were being found.

Two live instances were already in the repo's own tests. ApiKeysFacet.create accepts a past expiresAt and chk_auth_credentials_expires_after_created refuses that write on every dialect, so { expiresAt: Date.now() - 1 } is a key that can be created in dev and cannot be created in production; a totp fixture planted a non-string secret the same way. Both now plant past the write path with adapter.raw, which is how a corrupt row actually arrives — no database produces one through create.

Memory now applies the same rules and raises the same codes, and the two new compliance cases bind all four credential backends. That they pass unmodified against real Postgres and MySQL is the check that they describe the databases rather than the double: AUTH_ALREADY_EXISTS for the second password, AUTH_INVALID_PARAMETERS for the rest. Dropping either rule from memory fails its case; dropping both password unique indexes from the sqlite DDL fails the same case there. The unknown-kind rule is enforced but not pinned, because naming a kind outside Credential.Kind in a test needs a cast to get past the compiler that already forbids it everywhere real.

Not part of this. A dropped deleteByKind in PasswordsProvider.set was already caught by its own test, which asserts the identity holds one password row afterwards. The gap was the store contract, not that path.

Redis took session writes it would never read back

parseStoredSession is deliberately strict, and says so: an unknown kind or an aal outside 1–3 rejects the whole row rather than defaulting it, because "dropping it hands back an aal: 2 session with no factors that step-up reads as authoritative". The write path applied neither check.

So create returned success, the key was written, and every later getByHash answered AUTH_SESSION_REVOKED — the store disagreeing with itself about the same row. The caller is handed a sid for a session that was never readable: a cookie that authenticates nothing, with no error on the write that produced it. update had the same shape, and reached it from a patch rather than a create. Valkey wraps RedisSessionImpl, so it carried the bug too.

This is the production half of the divergence the previous entry found on credentials. Memory accepted the same writes and read them back, which is permissive but at least consistent; sqlite, pg and MySQL refuse them at the write with AUTH_INVALID_PARAMETERS, which is correct. Redis was the only store that said yes and then lost the row.

assertSessionAllowed now holds the rule once, in sessions.constants.ts, and both schemaless stores apply it on create and on update, raising what the dialects raise. One compliance case binds all six stores and asserts the refused patch left the row alone rather than half-writing it; it fails if the guard is dropped from redis's create, from memory's create, or from redis's update alone. The credential kind rule left unpinned by the previous entry is pinned too — the suite already casts to plant values a compiler forbids, which is the only way to reach a guard that exists for JS callers and dynamically built patches.

Measured and deliberately left. Three more session CHECKs have no equivalent in the schemaless stores: expires_at >= created_at, absolute_expires_at >= expires_at and rotated_at >= created_at. Applying them breaks 18, 9 and 4 unit fixtures respectively, all of which plant a backwards row to stand in for time passing. That is worth a decision rather than a quiet rewrite, because some of those tests describe states a SQL-backed deployment cannot hold: "refuses a session past its absoluteExpiresAt" needs absoluteExpiresAt behind expiresAt, which is exactly what chk_auth_sessions_absolute_expires_after_expires forbids. The gate that reads both deadlines is right to check both; what is unclear is whether the row it is being tested against can exist.

Release surface

FakeRedis and fakeRedis were exported from @gentleduck/auth/adapters/redis, a production entry, and shipped in its .js and .d.ts. They are a test double: nothing outside __tests__ imported them. They now come from @gentleduck/auth/test instead, and the RedisLike type stays on the adapter entry, since an app implementing the interface needs it and an app reaching for the double is writing a test.

Checked and clean. All 62 export subpaths resolve to built files and the bin does too; no dialect adapter imports pg, mysql2, better-sqlite3 or ioredis directly, so those stay dev-only and the caller's drizzle handle carries the driver; no adapter source carries a console.*, debugger or a TODO; no production module imports from src/test; the published ./test entry pulls in no vitest; and __isMemoryStore is branded on all four memory stores, which is what assertStrict reads to refuse them under env: 'production' — note that strict() is opt-in, called by the app at boot.

Left for a decision. src/core/drivers/redis-like.ts opens with "THIS WHOLE FILE IS NOT REVIEWED NEITHER TRUST BY ME ... it's generated by AI", and src is in files, so that sentence publishes to npm on the module backing the redis adapter. It is no longer true of the file's coverage — it has its own redis-like.test.ts and is exercised by 17 test files including the whole redis compliance matrix — but whether the code is trusted is the author's call, not a thing to quietly delete.

public-surface.test.ts guards only the root barrel, not the other 61 subpaths. That is the gap the FakeRedis export sat in.

5.10.0

Minor Changes

  • Follow-ups to the adapter rewrite: factories for every drizzle dialect, provenance on a provider link, and two audit fields the rewrite dropped without saying so.

    Factories for the drizzle adapters. 5.9.0 left new DrizzlePgAdapter(...) as

the only way to build one while memoryAdapter() was still there, so functional-style config worked for the dev adapter and for none of the ones that ship. Each dialect now exports drizzlePgAdapter / drizzleMysqlAdapter / drizzleSqliteAdapter, which mirror the class generics so withClient keeps its type.

There is no separate *Storage form. An adapter already satisfies Engine.Stores structurally, so stores: drizzlePgAdapter(db) is the whole call. memoryStorage() and the unused Config.IStorage duplicate of Engine.Stores are gone with it.

auth_identity_providers.added_by. A provider link is a new way to sign in as that identity, so an admin attaching one and the account holder attaching their own must not read alike - the same argument that put actor_id on auth_events. Nullable, stamped from the ambient actor on both create and link, in all four adapters, and readable as ProviderLink.addedBy. No updated_at/updated_by: provider_id and provider_sub are the row's identity, so changing either is a different link, which is why the column is added_at and not created_at. No soft delete either - a hidden link would still hold uq_auth_identity_providers_sub and block that login being attached anywhere else.

Identities.ProviderLinkInput omits addedBy and the facet's create now takes it rather than ProviderLink, because a caller passing its own would be forging provenance the ambient actor exists to record.

iamDecisionId is back on identity.impersonated. It was optional on the event through 5.8.0 and went missing in the rewrite, which never mentioned it. An impersonation that cannot be traced to the authorization that permitted it is the one entry an audit log cannot afford to lose, and the consumer writing that row had no other source for it.

Migration. New nullable column, so existing rows need nothing:

ALTER TABLE auth_identity_providers ADD COLUMN added_by text;

duck-auth migrate emits it.

5.9.0

Minor Changes

  • 7228f81: Storage adapters: one class per backend, implementing the engine's store contracts directly, plus a .wrap() escape hatch on every adapter call.

The bridge layer is gone

@gentleduck/auth/adapters/sql used to define a second, lower-level contract — 23 operations shaped around a where/patch pair — that each dialect implemented and sqlStores then translated into the four stores the engine actually binds. Two contracts for one job: every method was written twice, and the translation layer was where the miss-is-null rule, the actor stamping and the patchMetadata retry lived, invisible to the dialect that produced the row.

Each adapter now implements Identities.Store, Credential.Store, Sessions.Store and Events.Store itself. What the translation layer held is either inlined where it belongs or shared as a free helper on the new base.

Removed from @gentleduck/auth/adapters/sql: SqlBridge, SqlStore, SqlStores, sqlStores, SqlIdentityStore, SqlCredentialStore, SqlSessionStore, SqlEventStore, asBridge, orNull. The entry point keeps withSqlEventLog and the JSON-column codecs, which were never part of it, and is now @gentleduck/auth/adapters/drizzle: with the bridge gone there is no dialect-agnostic SQL contract left for the old name to describe, and everything behind it is shared only by the three drizzle dialects.

Renamed, with the two statics collapsed into one:

beforeafter
DrizzlePgBridge.bridge(db) / .storage(url)new DrizzlePgAdapter(db | url | pool)
DrizzleMysqlBridge.bridge(db) / .storage(url)new DrizzleMysqlAdapter(db | url | pool)
DrizzleSqliteBridge.bridge(db) / .storage(path)new DrizzleSqliteAdapter(db | path | client)
createSqlStores(bridge)— (an adapter is already the stores)

There is no open() static: the constructor takes the connection string, the driver pool or a drizzle handle and resolves it, so new is the only way in.

MemoryIdentityStore, MemorySessionStore, MemoryCredentialStore and MemoryOrgStore are no longer exported separately: MemoryAdapter is one class on the same base, with the same four facets. memoryAdapter(), memoryStorage() and MemoryAdapter#raw are unchanged.

One base class, one contract

@gentleduck/auth's adapter layer is now two declarations. Adapter is the contract and nothing else: the four stores, the Answer<T> a call returns and the Result<T> its wrap() hands back. AdapterStore is the class every adapter extends: it takes its driver's error mapper and exposes run, the one place a failure is typed and wrap is attached.

asAdapter is gone. It checked at runtime that an adapter had all four stores — something implements Adapter.Me already proves at compile time — and used that check to launder the profile type. DrizzlePgBridge.storage<MyProfile>(db) is now new DrizzlePgAdapter(db): an adapter answers the profile shape its table actually declares, so an app whose profile is narrower than that is told so by the compiler rather than trusted at runtime.

answering(promise) is now AdapterStore.answer(promise), and LOG_PAGE, stamped and rowWithLinks moved to @gentleduck/auth/adapters/drizzle, next to the schemas that use them.

A store keeps two of its six set-based writes

Identities.Store declared six optional set-based writes alongside the single-row ones — softDeleteMany, restoreMany, eraseMany, updateProfileMany, linkMany, unlinkMany — as a fast path a store could implement instead of being looped over. Four of them were real: every SQL dialect implemented softDeleteManyReturningIds, eraseManyReturningIds, restoreManyReturning and updateProfileManyReturning, and the sql bridge wired them onto the contract. linkMany and unlinkMany are the two nobody ever implemented.

softDeleteMany and eraseMany stay on the contract, and pg, mysql, sqlite and memory all implement them. Both are one statement that reports which of the named ids it touched, which is the whole of their per-row semantics — there is nothing for a dialect to get wrong that RETURNING does not already answer. The loop they replace costs two round trips per id for a soft delete, because a softDelete answering null cannot be told from one that matched nothing without a read in front of it.

restoreMany and updateProfileMany are gone from the contract and are facet loops now. Their per-row refusals — a closed grace window, an address a live row has taken since, a lost version race — were reimplemented once per dialect behind the old bridge, and a statement that reports which ids it touched cannot say which of those applied to the rest. The facet decides that once.

AuthIdentities#softDeleteMany and its five siblings are unchanged to a caller: one outcome per input row, in input order, each refusal named the way it was before.

Sessions keep theirs, and every store implements them

Sessions.Store kept three optional set-based forms — deleteAllForIdentities, deleteMany and listByIdentities — and for a while shared the identity side's problem: declared, consumed by the facet, implemented by nobody, so revokeAllForIdentities over 500 people was about a thousand round trips on Postgres where it should have been two statements. That is the one batch path with a caller that matters — "sign this whole org out" — so the answer here is the other one: every adapter implements all three.

pg and sqlite delete the set in one statement and read back the ids that matched; mysql has no RETURNING, so the ids are selected under their own lock first, the same read-then-delete _erase already does for credentials; memory makes one pass over the map instead of one per identity; redis batches the record delete, the expiry zrem and the per-identity srem into three round trips for the whole set rather than three per identity.

outcomesFromAffected is shared from core/batch — the ids a statement affected become one outcome per requested id, and an id that did not come back matched no row, which is the only thing one statement can say about it.

RedisLike.Client gains an optional mget. listByIdentity backs the active-devices screen and runs inside every revokeAllForIdentity, and it was issuing one GET per session; it now reads them all in one round trip, falling back to concurrent gets for a client without the command.

Credential.IStore.deleteByIdentities is gone. It had no implementation and, unlike the session forms, no caller either — nothing in the package ever invoked it, so it was a declaration and a compliance case and nothing else.

.wrap() — a failure as a value

Every adapter call now answers an Adapter.Answer<T>, a native promise with one extra method:

const { data, error } = await storage.identities.find({ email }).wrap();
if (error) return reply(error.code);

error is the discriminant and is always an AuthError, never unknown: a non-Error a driver throws becomes the cause of a real one rather than being handed back raw. Answer<T> extends Promise<T>, so awaiting it throws exactly as before and every existing caller — Promise.all included — is untouched.

Scope: wrap() catches what the call rejects with. An argument that throws before there is a promise still throws at the call site; a bug in the call is not a failure of the work.

error also carries the codes that call can raise, not a flat AuthError:

const { error } = await storage.sessions.update(id, patch).wrap();
//    ^? AuthError<'AUTH_SESSION_REVOKED' | Adapter.Faults> | null

Adapter.Faults is what any call can raise — the whole range of a driver's error mapper, since any statement can meet a constraint, a dead socket or a missing schema. Adapter.Me names what each method adds on top: restore can answer AUTH_GRACE_EXPIRED, patchMetadata and upsert AUTH_CREDENTIAL_NOT_FOUND, update AUTH_SESSION_REVOKED. The union is closed, so a switch on error.code is exhaustive, and comparing a read's error against a code it cannot raise no longer compiles.

AdapterStore.answer(promise) now types the failure on the await path too, as run always did, and attaches wrap to a promise of its own rather than to the caller's.

Each adapter declares its facets as their slot in Adapter.Me rather than checking an inferred object against the contract with satisfies. A satisfies only proves the body is assignable, so the type a caller saw came from the body — run's Adapter.Faults and nothing else — and the extra codes above were declared where no call site could read them. storage.identities.restore(id) now answers AuthError<'AUTH_GRACE_EXPIRED' | Adapter.Faults>, which is what this section always said it did.

Fixes this turned up

  • merge re-pointed the duplicate's credentials, sessions and logins and only then discovered the survivor was gone, leaving the duplicate destroyed for nothing. Both sides are confirmed before anything moves, on all three dialects.
  • link against an identity that no longer exists surfaced the foreign-key violation as AUTH_IDENTITY_NOT_FOUND; it now answers null, as every other read of a missing row does.
  • A soft delete no longer takes deletedAt/deletedBy from the caller: the window is a grace period in milliseconds and the actor is the ambient one, so two dialects can no longer disagree about when a row becomes purgeable.
  • An empty session patch is a no-op rather than a driver syntax error, and a missing session answers AUTH_SESSION_REVOKED on every backend.
  • patchMetadata on a row that is not there (or not this tenant's) answers AUTH_CREDENTIAL_NOT_FOUND rather than telling the caller to retry.

One read per adapter, and no handle to pass

Each dialect had a private read taking the client to read through, so all ten of its call sites passed this._db to say "the usual one". The read now uses the adapter's own handle, and a transaction binds an adapter to its tx — this.withClient(tx) — so nothing is threaded anywhere and no call site can quietly read outside the transaction it is in.

MySQL had three row helpers where the others had one: a locked re-read, a links read and an assembler over both. It now answers every lookup through the same single join the other two dialects use, leaving one helper — the lock a write takes before it re-reads.

On Postgres, create and erase are one statement each rather than a transaction around two. The row, its logins and the read that answers them travel as one CTE, with the links taking the id straight out of it; erase reads the logins in the same with that deletes their owner, since every arm of one sees the snapshot the statement opened on. Four round trips become one, and the row an erase answers with now carries the logins the cascade took — proven for every adapter, not just this one.

One withClient, on the adapter

A transaction was joined store by store: every store contract carried its own optional withClient, each dialect implemented four one-line delegates back to the adapter's own, and withTransaction called them one at a time. The facets come off one adapter and share its connection, so that was four answers to one question.

withClient belongs to the adapter now — Adapter.Me declares it, the three drizzle adapters already had it, and withTransaction makes the one call. The capability hook follows: MfaFacet and ApiKeysFacet are handed the bound facets instead of a driver handle to rebind a store of their own from, so Capability.withClient(stores, events) replaces (client, events) and neither can answer null any more.

Hand the engine the adapter — stores: adapter — and transactions work. A bag assembled facet by facet (redis sessions beside a drizzle identities store, say) has no withClient, and withTransaction throws AUTH_MISCONFIGURED saying so rather than leaving a write outside the caller's transaction. createTest takes the same shape: one stores override in place of four per-facet ones.

Fewer statements per call

What a store call costs on a real database is mostly the number of statements it sends: each one is a round trip the caller waits through. Four shapes sent more than they had to, on every dialect that had them.

link read the identity to check it was there, inserted the login, then read the identity again to answer. The first read is gone — the insert takes its row from auth_identities, so an identity that is gone or hidden writes nothing and the read that answers returns null on its own. MySQL also held a locked read and a second read for "does this identity already hold this provider", both inside a transaction; the repeat is settled in the same where now. Three statements to two on Postgres and SQLite, six to two on MySQL, and linking to a soft-deleted identity no longer writes a row on MySQL that the answer then hides.

patchMetadata read the row, merged in JS, wrote against the version it had read and retried once when a concurrent write took that version first. It is one update now — metadata || $patch::jsonb on Postgres, json_set on SQLite and MySQL — so the merge happens under the row's own lock and there is no window to lose. Not json_patch/json_merge_patch: those are RFC 7396, where a null value removes the key rather than storing it, which is not what the object spread the memory adapter runs does.

merge read the survivor's credential kinds and provider ids to decide what the duplicate could keep. Both reads are now conditions on the writes that used them, and the logins that clash stay where they are for the cascade under the final delete to take, rather than being cleared by a statement of their own.

MySQL took a select … for update before update, softDelete, rotate, revoke and patchMetadata to learn whether the row was there at the version expected. The write takes the same lock and affectedRows answers the same question, so the probe is gone from all five, and restore no longer reads the row twice. erase keeps its locked read: it has to answer with the row it removed, so that read comes before the delete either way.

The memory adapter's create checked the address, the handle and each login in a pass of its own; it is one scan now, which is also the one place the three unique indexes every dialect carries are written down together.

One fix fell out of it: patchMetadata could raise AUTH_STALE_WRITE after losing the version twice, a code its own fault union does not name and a retry its caller could not act on. There is no version to lose now, so the only failure left is the AUTH_CREDENTIAL_NOT_FOUND the contract declares.

bun run bench:adapters times all 26 store calls on all four adapters. The before and after tables, per call, are in src/adapters/README.md.

An identity store answers one read. findById, findByEmail and findByProviderSub are find(by), named with { id }, { email } or { providerId, providerSub } — three names for one join, one live filter and one order, where only the condition ever differed. auth.identities.getById and its siblings are unchanged; it is stores that change shape, so stores.identities.findById(id) is now stores.identities.find({ id }).

The where clauses that scope a read to a tenant were each a mutable array and a pair of ifs; they are one and(...) with a shared inTenant filter that drops out when the caller named no tenant.

MySQL read an identity by address 50x slower than by any other key. The where matched lower(profile ->> '$.email') while the unique index lives on the email_norm column generated from that expression, and MySQL will not use a generated-column index when the comparison carries the connection's collation - so every lookup scanned the table. It matches the stored column now: 24 ms to 0.28 ms over 50k rows. No test could see it, since every assertion passed either way on a table of ten rows.

bun run bench:adapters is what found it: the same 50k identities and 100k logins on all four adapters, timed at the adapter boundary. The numbers, the plans behind them and the method are in src/adapters/README.md.

One way a driver failure is named

Each dialect had its own translator: the same five makers, the same socket-code list and its own four-deep cause-chain walker, restated three times, with a Make that took one argument in two of them and two in the third.

There is now one reader — signalOf — and one resolver, and each dialect is only its own table of what its driver says: the text it names the refused index in, the code or errno it gives, the family that code belongs to, read in that order. 256 lines became 189, and nothing an adapter answers changed.

AuthError itself no longer declares each code's HTTP status twice — once as a literal on the union member and once in the status table that is the only one ever read — and the class is free of type assertions: the origin argument is narrowed by a predicate, and toJSON scrubs through a function that returns a record rather than one that is cast to it.

5.8.0

Minor Changes

  • Add an optional post-authentication hook to the NestJS nestSignIn handler.

    nestSignIn was sealed: it ran CSRF, body parsing, the sign-in flow and intent execution

with no seam between authenticating and responding, and forward/executeIntents are module-private. An app that had to refuse a sign-in on something the engine cannot know — a host allow-list, a suspended tenant, a per-identity block — had no way to do it without reimplementing the whole handler.

nestSignIn(auth, {
  onAuthenticated: (outcome, req) =>
    outcome.session?.identityId
      ? denialFor(outcome.session.identityId)
      : undefined,
});

The hook runs after the credentials verify and the session row exists, before any intent reaches the response. Returning a NestSignInDenial revokes the session that was just created and answers with the error instead, so the client never receives the SID.

Revoking is the library's job and not the caller's, deliberately: nestSignIn owns the sid, and a consumer who forgot the revoke would leave a live session sitting behind a 403 — a silent auth bypass. For the same reason a hook that throws also revokes, and a denial the adapter cannot read (a blank code, or a 2xx status that would render the refusal as a success) still denies, at 403.

Gating here rather than in front of signIn also means a caller has to prove the password before it learns anything, so a denial is not an enumeration oracle.

nestSignIn(auth) is unchanged — the options argument is optional and every existing call site keeps its current behaviour. Adds the NestSignInDenial and NestSignInOptions types to @gentleduck/auth/server/nestjs.

5.7.1

Patch Changes

  • Export StoredProviderLink and StoredFactor from the drizzle entrypoints, so a consumer can name the types the tables now infer.

    5.7.0 moved the JSON columns onto customType, which put those two names into

authIdentities.$inferSelect and authSessions.$inferSelect. They were only reachable through an internal bundle chunk, so anything that infers a type from a table rather than annotating it — a repository base class, a generic wrapper, getTableColumns — failed to compile:

TS2883: The inferred type of 'AuthIdentitiesRepository' cannot be named without a
reference to '.../dist/index-CRY_TusL.cjs'. This is likely not portable.
A type annotation is necessary.

Both types are now re-exported from @gentleduck/auth/adapters/drizzle/pg, /mysql and /sqlite alongside the tables they describe, which is the module a consumer already imports. They remain available from @gentleduck/auth/adapters/sql too.

Types only — no runtime change, and nothing to migrate.

5.7.0

Minor Changes

  • Framework adapters now bind the request's actor scope, so writes carry provenance.

    withActor, runWithAuditEnvelope and setDefaultActorResolver all existed,

and events.audit.ts documented the intended adapter wrap in its own docblock — but no adapter shipped it. A grep for withActor across src/server/ returned nothing, so created_by / updated_by / deleted_by and Events.Envelope.actorId were null on every write a request drove, whether or not the request was authenticated.

New, one per adapter:

  • middleware, for frameworks that compose a next — expressActorContext(auth), koaActorContext(auth), honoActorContext(auth), nestActorContext(auth)
  • handler wrappers, for those that do not — nextWithActor(auth, handler), fastifyWithActor(auth, handler), elysiaWithActor(auth, handler)
  • withGrpc binds internally; it already had the resolved session in hand

The primitives are exported from @gentleduck/auth for anything hand-rolled: withRequestActor(auth, req, fn), withResolvedActor(session, fn) and actorForSession(session).

While impersonating, the actor is the operator behind actingAs, not the account being acted on: the subject is already the row being written, and the column exists to name the human accountable. That id reached audit events through auditEnvelopeFor and never reached the provenance columns.

An anonymous request, and one whose session will not resolve, both run unbound — a stale cookie on a public route must not become a 500, and refusing it is a guard's job. Both leave the actor null, which stays distinct from "the user did this themselves". nestActorContext reuses the session makeGuard resolved, so the pair costs one resolveSession rather than two.

  • Adapters can now check the request fingerprint, not just stamp it at sign-in.

    hijack.evaluate and resolveSession's requestSnapshot both existed, and the hijack policy

(onIpChange, onUserAgentChange) could be configured, but no adapter read an IP or a User-Agent on the resolve path - so the pair recorded on the session was written at sign-in and never looked at again. The policy had no effect and the anomaly detectors never ran.

Following the shape duck-iam uses for its request environment, each actor-context wrapper now takes an optional getCaller, and each adapter exports the reader to pass it:

import {
  expressActorContext,
  expressCaller,
} from "@gentleduck/auth/server/express";

app.use(expressActorContext(auth, { getCaller: expressCaller }));
  • expressCaller, koaCaller, fastifyCaller, nestCaller, elysiaCaller, honoCaller, nextCaller read what the framework resolved and never a forwarded header - the host is the only layer that knows how many proxies it trusts. nextCaller reports the User-Agent only, as a Web Request carries no resolved peer address.
  • Supplying getCaller forwards an Anomaly.RequestSnapshot to resolveSession and compares the fingerprint with hijack.evaluate, which emits suspicious on any drift even when the configured reaction is 'ignore'.
  • onHijack handles drift instead of the configured reaction, and is where 'rotate' belongs: rotating writes a new session cookie onto the response, which a wrapper that only owns handler execution cannot do. Without it, 'mfa' and 'revoke' throw and 'rotate' is audit-only.

This is opt-in. Omit getCaller and the wrappers stay what they were - an attribution scope that refuses nothing - because switching it on starts acting on IP and User-Agent drift for sessions that were already issued.

Two fixes fall out of the same work:

  • callerContext now truncates to the lengths SessionsImpl.create actually stores (exported as SESSION_COLUMN_CAPS: 64 for ip, 512 for userAgent, 256 for fingerprint). Normalising to a different length than the row would make a client with a long User-Agent compare unequal to its own stored value on every request - a permanent step-up loop.

  • The Nest adapter's private callerOf is folded into nestCaller, so its sign-in route normalises its fingerprint the same way every other adapter does.

  • Breaking, in a minor. Drizzle tables now hand back the types they declare for their JSON columns. $inferSelect on authIdentities and authSessions changes shape, and the runtime values change with it, so anything reading these tables directly needs a look before upgrading — the version number will not warn you, this note is the warning.

    authIdentities.providers, authSessions.factors and authSessions.actingAs

carried .$type<T>() annotations naming Date — a compile-time assertion and nothing else. What the driver actually returned was the ISO string JSON.stringify had written, so a direct db.select().from(authIdentities) — a supported read, since the tables are a public export — gave providers[0].addedAt.getTime is not a function, and addedAt < new Date() was quietly always false. Revival existed, but only inside createSqlStores, which a direct table select never reaches.

Each of the three columns is now a customType whose fromDriver does the parsing. The SQL type is unchanged (jsonb / json / text), so this is not a migration. Unreadable values fail closed rather than becoming an Invalid Date: addedAt and completedAt read as null at the column and fall back to the row's own createdAt in the store, which is the layer that can see it; an actingAs whose window cannot be read is dropped.

Two latent bugs surfaced with the types: the mysql bridge's listByIdentities and every sqlite session read returned rows without reviving them at all, and the pg and mysql bridges revived dates with new Date(value as string), which turns corrupt input into an Invalid Date that satisfies instanceof Date and compares false against everything.

What breaks, and how to migrate

typeof authIdentities.$inferSelect used to say providers: ProviderLink[] with addedAt: Date while the value was a string. It now says StoredProviderLink[] with addedAt: Date | null, and the value matches. authSessions.factors moves the same way (StoredFactor, completedAt: Date | null), and actingAs is now Sessions.ActingAs | null.

Two shapes to look for:

  • A workaround that re-typed the column as a string. The honest thing to write against the old behaviour was Omit<typeof authIdentities.$inferSelect, 'providers'> & { providers: { addedAt: string, ... }[] }. That is now wrong in the other direction, and because it is an Omit and replace rather than a narrowing it keeps compiling: the type says string, the value is a Date, and nothing reports it. Drop the override and take the table's own type.
  • Code that assumed addedAt / completedAt is always present. They are Date | null at the column now, because an unreadable value reads as null rather than as an Invalid Date. Read rows through the store, or through the exported reviveIdentityRow / reviveSessionRow, and the fallback to the row's createdAt puts a real Date back.

Anything going through createSqlStores or the drizzle*Storage helpers is unaffected — those already revived, which is why the mismatch stayed hidden.

The parsers are exported from @gentleduck/auth/adapters/sql (storedDate, parseProviders, parseFactors, parseActingAs, fromJsonColumn, isProviderLink, isFactor, reviveIdentityRow, reviveSessionRow, and the StoredProviderLink / StoredFactor types), and the wire-side converters from @gentleduck/auth/client/vanilla (reviveSession, reviveIdentity, reviveSessionResult), so an app reading these tables or calling /session directly can use the same conversion the adapters do instead of hand-rolling one.

5.6.0

Minor Changes

  • The client hands back the Dates its types promise, and the wire shape is now written down so it cannot drift again.

    Response.json is JSON.stringify, so every Date on a session or identity row left the server as an ISO string. Sessions.Me and Identities.Me declare all of them as Date, and a cast at the end of getSession stopped tsc from noticing. session.expiresAt.getTime() threw; session.expiresAt < new Date() compared a string to a Date and was always false, so a session never looked expired. getSession now revives createdAt, rotatedAt, expiresAt, absoluteExpiresAt, factors[].completedAt, actingAs.startedAt/.expiresAt, createdAt/updatedAt/deletedAt and providers[].addedAt. The react, vue, solid and svelte clients all wrap this one, so they inherit it.

    Fields are named, not sniffed by shape: identity.profile is the consumer's own data and is left untouched. A value that will not parse stays the string it arrived as rather than becoming an Invalid Date, which would satisfy instanceof Date and compare false against everything.

    New: VanillaClient.Serialized<T>, SerializedIdentity and SerializedSessionResult describe a row as it actually crosses HTTP. Serialized<T> is not assignable to T, so revival is the only route between the two and skipping it is a compile error — the previous version was correct only by convention, which is what let this ship.

    Also fixed on the same path: an enveloped-free 200 with an empty body parses to null, and that null was returned as data under a signature promising a SessionResult. It now resolves to { session: null, identity: null }.

    The OpenAPI Session schema had the same disagreement from the other side: expiresAt, absoluteExpiresAt and factors[].completedAt were documented as integer when the handler sends ISO strings, so every generated client typed them number and a response validator would reject a valid reply. They are now string / format: date-time, createdAt and rotatedAt are documented too, and tenantId is ['string','null'] - it is null on every session of a single-tenant deployment. A test serialises a real row and checks each documented field against it, so the spec cannot drift from the wire again.

Patch Changes

  • Type-consistency pass: the package now reads untyped shapes one way, and the assertions that were left are the two that cannot be avoided.

    Three dead casts in the drizzle adapters. drizzleSqliteStorage and drizzleMysqlStorage laundered their bridge with as unknown as SqlBridge.Me<Profile> and pg used a single as, under a comment claiming the cast asserted the concrete Profile shape. It never did: createDrizzle*Bridge is already generic and already returns SqlBridge.Me<Profile>. All three now pass the type argument explicitly and assert nothing, so a future divergence between a bridge and the store contract is a compile error instead of being swallowed - which is exactly what a double cast had been positioned to hide.

    mfa.ts read credential metadata three ways in one file. verifyTotp and hasTotp used the shared isProfileBooleanTrue reader; confirmTotpEnrollment, the TOTP replay guard and the AAL-3 passkey check cast metadata straight to an interface. The casts happened to be safe - each compared strictly - except the replay guard, which read lastTotpStep through typeof x === 'number'. That accepts NaN, and step <= NaN is false, so a NaN there would have waved a TOTP replay through. All three now use the shared readers.

    New: getProfileNumber and isProfileBooleanFalse alongside the existing credential predicates. getProfileNumber rejects NaN and Infinity rather than returning a number that loses every comparison. isProfileBooleanFalse is deliberately not the negation of isProfileBooleanTrue - an absent key is neither, which is what tells "explicitly not yet confirmed" apart from "never had the field".

    The compliance harness had 68 identical casts. runIdentityStoreCompliance is generic over P, so a profile literal was not assignable to it and every fixture wrote { email, username } as unknown as P. Every adapter instantiates the suite with the same two keys and the suite exercises no others, so that is now one documented assertion in one helper instead of sixty-eight scattered ones.

    Outside two dynamic import() calls for optional peer dependencies, no as unknown as remains in the package. Production code has no any and no @ts-expect-error.

5.5.1

Minor Changes

  • Audit round: captcha reaches the engine, three config keys stop being dropped on the floor, and the client stops handing back strings where the types promise Date.

    Every entry below this one in the release notes came out of the same sweep. The shape that

recurs: something the type system already promised, that no code path actually delivered — a config key copied across by hand and forgotten, an option declared on the wrong half of an interface, a field a projection never populated, a Date that survived JSON.stringify as a string. None of it failed loudly, which is why it lasted.

Two signatures gained required arguments. beginPasskeyRegistration now takes credentialStore and tenant — what it needs to read the identity's live passkeys and send excludeCredentials; completePasskeyRegistration already took both. authRefreshoauthToken now requires the identities probe, so a refresh token cannot outlive the identity behind it. Both are a compile error to miss, and both arguments are already on hand wherever the library itself calls them.

The captcha default refuses rather than passes, so a host that never configured one sees no behaviour change unless it was relying on an unconfigured verifier to succeed — which is the thing being fixed.

  • cancelAccountDeletion gains the user's route: a single-use undo token.

    Until now it had exactly one gate — a mandatory authorize(identityId) callback. That is

the operator's route. A user clicking "undo" in their mail has the mail and no admin rights, so the grace window that completeAccountDeletion advertises as restorableUntil was reachable only by opening a support ticket.

completeAccountDeletion now mints an undo token after the soft delete lands — hashed, single-use, kind: 'recovery' under purpose: 'account-deletion-cancel', expiring at exactly restorableUntil. Plaintext comes back once as cancellationToken. Pass the new optional channels (and channel / callbackPath) and the library mails the undo link under templateId: 'account-deletion-cancel'; omit them and delivery is yours. A host that does not want undo simply drops the token — nobody else ever holds the plaintext.

cancelAccountDeletion takes one gate or the other:

await auth.flows.cancelAccountDeletion({ token }); // the user
await auth.flows.cancelAccountDeletion({ identityId, authorize }); // an operator

Both, or neither, is AUTH_MISCONFIGURED. Resolving "both" in favour of one would mean a bad token silently falling back to a callback that says yes, and which gate applied would depend on a precedence rule invisible at the call site — the exact ambiguity this function used to be when it took an id and no gate at all. The token names its own subject, so no identityId accompanies it and an undo link cannot be pointed at another account.

Breaking.

  • Flows.AccountDeletionCancelInput is now a union of AccountDeletionCancelByToken and AccountDeletionCancelByAuthorize. Existing { identityId, authorize } callers are unchanged; callers that spread a wider object may need to narrow it.

  • completeAccountDeletion returns an extra cancellationToken field and writes one extra credential row per deletion.

  • 'account-deletion-cancel' is a seventh recovery purpose. Anything reading kind: 'recovery' without filtering on metadata.purpose now sees one more row — see RECOVERY_PURPOSES.

  • ec9629b: Provenance: audit columns that the API could not fill are now filled, and the ones that could only ever repeat another column are gone.

    auth_identities, auth_credentials and auth_sessions have declared created_by and updated_by since 5.x. Nothing in the package wrote them and they were not on the row types, so every row in every deployment carried NULL provenance — the schema promised an audit trail the API could not produce.

    The ambient actor context (mirrors the existing tenant one):

    import { withActor } from "@gentleduck/auth";
    
    await withActor(req.user.id, () =>
      auth.identities.update(id, patch, version)
    );
    
  • withActor(actorId, fn) binds an actor across awaits. withActor(undefined, fn) clears the scope, the same fence withTenant provides.

  • resolveActor on AuthEngine config is the process-wide fallback for hosts that already have a request context, so provenance does not depend on remembering to wrap every call. Also settable directly via setDefaultActorResolver.

  • Precedence is explicit → ambient → configured default → null. Nothing bound records null, which is true; no 'system' placeholder is invented.

  • identities.erase(id, { reason, operatorId }) used to accept operatorId and do void opts. It now binds it — and only when present, so an omitted one leaves an outer scope intact.

Which tables carry what, and why:

  • auth_identities — created_by, updated_by, and a new deleted_by. "Who deleted this account" is the audit question that actually gets asked, and deleted_at alone could not answer it. Set with the delete marker, cleared by restore, so the pair never disagrees.
  • auth_credentials — created_by, updated_by. Distinguishes a self-service password reset from an admin one.
  • auth_sessions — both columns dropped. A session's author is its identity_id, and the one case where the operator differs is impersonation, which acting_as already models. A column that can only repeat another column drifts from it.
  • auth_events — new actor_id. identity_id is the subject; this is the operator. Without it "admin X revoked user Y's session" was indistinguishable from "user Y revoked their own" — the one thing an audit log exists to record. Events.Envelope gained a matching actorId, stamped onto every audited event.

Uniqueness is now a database guarantee on every dialect, and a typed error everywhere:

  • MySQL had none. It cannot index a JSON path directly, so email and username uniqueness was left to an application-layer check while pg and sqlite had real partial indexes. That is not a weaker guarantee, it is no guarantee — the check is read-then-write, so two concurrent signups both saw a free address and both got it. auth_identities now carries two generated STORED columns, email_norm and username_norm, that are NULL while a row is soft-deleted; MySQL permits any number of NULLs in a unique index, so this reproduces pg's and sqlite's WHERE deleted_at IS NULL partial indexes exactly. MySQL also gained the chk_auth_identities_profile_shape check the other two have had since 5.x.
  • sqlite's unique indexes were declared in a form drizzle-kit could not emit. The index expression json_extract(profile, '$.email') contains a comma, which the generator splits on to find column names, so it produced DDL naming two nonexistent columns — anyone running drizzle-kit push/generate against the sqlite schema got no unique index at all. Both indexes now use ->>, which SQLite has had since 3.38 and which mirrors what pg already did. The dialect's own email lookups were switched to the same expression so they can still use the index.
  • A violation of either index now arrives as AUTH_EMAIL_TAKEN or the new AUTH_USERNAME_TAKEN, on every dialect, instead of a raw driver error. The pre-checks in the store are read-then-write and so are racy by construction; the index is what actually decides, and its answer used to surface as a 500 while the pre-check's surfaced as a 409. The driver error stays reachable on cause.
  • The memory adapter enforced email on create and restore, username nowhere, and neither on update — so moving an identity onto another live row's email succeeded there and failed on every SQL dialect. All three paths now check both, and the rule moved into the shared store-compliance matrix so no adapter can drift from it again.

Also fixed here:

  • restore and restoreMany no longer resurrect a provider login someone else claimed while the row was hidden. findByProviderSub skips soft-deleted rows, so the sub was genuinely free to take — and restoring past that left two live rows answering to one Google account, with lookups returning whichever the query plan ordered first. Now refused with AUTH_PROVIDER_TAKEN (batch reason provider-taken), mirroring the existing email guard. In the batch path the two clashes are checked before either is committed, so a row refused for its address does not go on to reserve its provider subs against later rows in the same call.
  • restoreMany now reports which rule refused each row instead of guessing. It previously labelled every refusal email-taken, because that was the only clash there was; a provider clash would have arrived under the wrong reason.
  • duck-auth migrate emitted DDL that had drifted from the declared schema: email_verified, created_by, updated_by and both updated_at columns were missing, so a database built from the CLI could not satisfy the adapter's own writes.
  • duck-auth migrate did not emit auth_events at all — the audit log, the one table this release is about, was left for consumers to hand-write. It is now emitted, indexes included.
  • The CLI emitted an auth_identities.tenant_id that no query in any dialect reads or writes. It was declared in the MySQL schema too, and indexed there. Dropped from both: it was a column that existed, filled itself with NULL forever, and read as data — the same defect this release removes from auth_sessions.
  • The drift test that was supposed to catch all of this checked one direction over three tables. It now checks both directions over four: a column the schema declares and the CLI omits breaks writes outright, and a column the CLI emits that nothing declares is the quiet one that becomes permanent NULL.
  • The sqlite adapter tests built their tables from DDL hand-written inside the test files — no foreign keys, no unique indexes, no checks, plus the same phantom tenant_id. The suite was passing against a schema no deployment has: session ids that violate chk_auth_sessions_id_length, credentials pointing at identities that do not exist. Both files now build from the generated schema (bun run e2e:schema), and 33 cases that only passed because the constraints were absent were fixed to use real ids and seed their foreign keys.

Breaking:

  • Identities.Me and Credential.Me gain required createdBy/updatedBy; Identities.Me also gains required deletedBy. Sessions.Me loses createdBy/updatedBy and Sessions.CreateInput no longer omits them. Code that builds these rows by hand — fixtures, custom stores — must follow; code that only reads them is unaffected.
  • SqlBridge custom implementations: softDelete and softDeleteManyReturningIds take a trailing deletedBy; restore must clear deleted_by; updateProfileManyReturning receives updatedBy in the patch. A bridge passing the patch through needs no change; one naming columns by hand, as the Postgres dialect does, must add them.
  • SqlBridge.restoreManyReturning may now return refused: { id, reason }[] alongside candidates and restored. It is optional so an existing bridge still compiles, but a bridge that guards provider subs and does not report them will have those refusals reported as email-taken — only the dialect ran both clash queries, so only the dialect can say.
  • New error codes AUTH_PROVIDER_TAKEN (409) and AUTH_USERNAME_TAKEN (409), and new batch failure reasons provider-taken and username-taken. Exhaustive switches over either will not compile until extended.
  • Writes that used to fail with a raw driver error on a duplicate email or username now throw AuthError. Code matching on the driver's message or error number will no longer match; match on code instead, or read cause.
  • MySQL only: auth_identities gains two generated columns. They are index carriers, not part of the row contract — a bare SELECT * on that table now returns them, and the adapter excludes them from its own selects. A custom query that spreads the row into an Identities.Me should do the same.

Migration. created_by/updated_by on identities and credentials already exist. New: auth_identities.deleted_by, auth_events.actor_id. Droppable: auth_sessions.created_by, auth_sessions.updated_by. Rows written before this release keep their NULL provenance.

ALTER TABLE auth_identities ADD COLUMN deleted_by text;
ALTER TABLE auth_events     ADD COLUMN actor_id   text;
ALTER TABLE auth_sessions   DROP COLUMN created_by, DROP COLUMN updated_by;
ALTER TABLE auth_identities DROP COLUMN tenant_id;  -- never read or written

-- "Everything operator X did", on an append-only table that only grows.
CREATE INDEX auth_events_actor_created ON auth_events (actor_id, created_at);

MySQL additionally needs the uniqueness that dialect never had. Resolve any existing duplicates among live rows first — the index build fails otherwise, and that failure is the pre-existing duplicates being reported, not a migration bug:

ALTER TABLE auth_identities
  ADD COLUMN email_norm VARCHAR(320)
    GENERATED ALWAYS AS (if(deleted_at is null, lower(profile ->> '$.email'), null)) STORED,
  ADD COLUMN username_norm VARCHAR(191)
    GENERATED ALWAYS AS (if(deleted_at is null, lower(profile ->> '$.username'), null)) STORED,
  ADD CONSTRAINT uq_auth_identities_email UNIQUE (email_norm),
  ADD CONSTRAINT uq_auth_identities_username UNIQUE (username_norm);

sqlite databases built from a previous drizzle-kit run have no unique indexes at all — the generator could not emit them. Deduplicate live rows, then:

CREATE UNIQUE INDEX uq_auth_identities_email
  ON auth_identities ((lower(profile ->> '$.email'))) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_auth_identities_username
  ON auth_identities ((lower(profile ->> '$.username'))) WHERE deleted_at IS NULL;
  • b06c50b: cancelAccountDeletion requires an authorize callback.

    It checked that identityId was a plausible string and then restored the account. No token, no session, no callback. Anyone who could reach the function un-deleted any account by id — including one deleted deliberately, by a user who wanted it gone.

    Every sibling in that flow is gated: completeAccountDeletion requires a single-use token, impersonate refuses to run without an authorize callback. This one was gated by nothing, and its own docstring called it an "optional admin cancel route" — an assumption the signature neither stated nor enforced.

    await auth.flows.cancelAccountDeletion({
      identityId,
      authorize: async (id) => currentUser.isAdmin && currentUser.canRestore(id),
    });
    
  • Mandatory, not optional. There is no default the library could pick that is safe; only the host knows who is asking. Required in the type, so every existing call site fails to compile until it says who may cancel.

  • Checked before any read or write. A refusal never reaches the store, so it cannot be observed as a restore-then-undo or timed against one.

  • A refusal reports AUTH_UNAUTHENTICATED — the same code an unknown identity gets. Distinguishing them would turn the endpoint into a way to ask which accounts are sitting in the deletion grace window.

  • A missing callback is AUTH_MISCONFIGURED. TypeScript refuses the call, but a JavaScript host — or an options object built from parsed input — reaches it anyway, and a missing gate must not read as permission granted.

  • The callback receives the id, not the identity: the row is soft-deleted at that point and cannot be loaded. Resolve the caller from your own request context.

Breaking: Flows.AccountDeletionCancelInput gains a required authorize: (identityId: string) => Promise<boolean>. Every call site must supply it.

Not covered: a cancellation token, for a user clicking "undo" in an email without admin rights. Nothing issues one today — requestAccountDeletion mints a deletion token that completeAccountDeletion consumes — so that path needs a second credential with its own TTL, channel and template. Until it exists, a user-facing undo link is the host's to authorize like any other route.

  • auth.captcha exists, and refuses instead of passing when it was never configured.

    Engine.Cfg had no captcha key at all, so a host that wanted a challenge kept a second

verifier next to the engine with its own wiring, and auth.captcha was undefined. It is now a first-class member: createAuth({ captcha: authTurnstileVerifier({ secret }) }), read back as auth.captcha.

Unconfigured, auth.captcha is an AuthUnconfiguredCaptchaVerifier — every call answers { success: false, errorCodes: ['captcha-not-configured'] }. Deliberately not AuthNullCaptchaVerifier, which always passes and ships for tests: a host writes if (!(await auth.captcha.verify(...)).success) throw, deploys with the secret unset, and an always-pass default lets every bot through while the code reads as though a captcha is enforced. Pass authNullCaptchaVerifier() to opt into always-pass. Both, plus the Turnstile / hCaptcha / reCAPTCHA v3 verifiers, are exported from @gentleduck/auth/core.

Also fixed: createAuth copied engine-config keys across by hand and dropped the ones nobody remembered to add. AuthDefine.Cfg inherits them from Engine.Cfg, so a dropped key still type-checks at the call site and then silently does nothing. resolveActor was one: a host that wired an actor resolver the documented way wrote created_by / updated_by / deleted_by as NULL, which is the audit trail going missing for exactly the caller who took the trouble to configure one. captcha would have been the next. createAuth now spreads its config into the engine, so it cannot forget a key that is added later.

  • caf7ddf: The rest of the critical and high findings in core/flows: an enumeration oracle, a delete that took three other flows' tokens with it, a signup that produced rows Postgres refuses, and a signup with no rate limit.

    requestPasswordReset no longer answers, by clock or by error, whether an address is registered.

    The unknown-address branch was a token gesture — one sha256 — against a write and two reads on the known one. Separable by response time from anywhere. The library already had the correct version of this defence in passwords.ts, which runs a full hash against NO_IDENTITY_SENTINEL so the unknown path costs what the real one costs; the reset flow gestured at it without achieving it.

    Both branches now mint a token, hash it, and make the same three store calls in the same order, against the same sentinel. What could not be mirrored is the write — auth_credentials.identity_id is a foreign key, so there is no row to hang a decoy on — leaving one write against one read on the same table, down from one hash against three round-trips. The residue is pinned as a FINDING: rather than papered over.

    The louder half was not a timing signal at all: a channel that was not configured threw for an address that exists and returned {ok:true} for one that does not, saying it in plain language in the response body. The channel check moved above the identity lookup, so a wiring fault is a wiring fault regardless of who asked.

    One consequence worth stating: requireMfa() now runs on both branches, so a deployment with no MFA provider gets AUTH_PROVIDER_NOT_REGISTERED from either — the flow has always needed the provider for a known address, and the asymmetry was itself the oracle.

    requestEmailVerification no longer voids three other flows' tokens.

    It ran deleteByKind(identityId, 'recovery'). Four different flows share kind: 'recovery' — password-reset tokens, email-verification tokens, account-deletion tokens, and signup-flow state — and they are told apart only by a metadata field, which deleteByKind cannot read. So asking for a verification mail silently destroyed an in-flight password reset, a pending deletion confirmation, and an in-progress signup, stranding the user with a flow token that no longer resolved.

    Now it lists the identity's recovery rows and deletes only those whose purpose is email-verification — the same list-filter-delete requestAccountDeletion already used. It still replaces its own stale token, so two requests never leave two live links.

    One discriminator, written by everything. That fix is only correct if every flow supplies a purpose, and two did not: signup and password-reset wrote metadata.kind while getCredentialPurpose() — the helper every guard and every delete reads — reads metadata.purpose, and so returned undefined for both. All four now write purpose, and the two flows that reached into metadata.kind by hand read the helper instead.

    beginSignUp builds a profile the library's own Postgres adapter accepts.

    Identities.ProfileMetadataBase requires username. duck-auth's pg schema enforces it with chk_auth_identities_profile_shape plus a unique index on lower(profile->>'username'). And beginSignUp built { ...initialProfile, email } — no username — then cast past its own type with as unknown as Profile. Since initialProfile is optional, beginSignUp({ email }), the documented happy path, produced an INSERT the library's own adapter rejects. Nothing caught it because the sqlite conformance DDL states in its own comment that it omits CHECK constraints, and memory and Redis have no schema at all.

    username is now derived from the address when the caller supplies none, and the double cast is gone. From the whole address rather than its local part: username carries a unique index too, so deriving sam would refuse the next sam@b.com because sam@a.com signed up first — a collision on a handle neither user chose.

    beginSignUp consumes the rate limiter. It was the only flow in the unit that never did; password-reset, email-verification and account-deletion all do. One unauthenticated request equalled one permanent identity row, unbounded. Keyed per canonical address, so hammering one victim's address is what gets capped.

    Breaking:

  • beginSignUp throws AUTH_RATE_LIMITED when the limiter trips. A host with a tight limiter and a signup retry loop will start seeing it.

  • beginSignUp writes a username into the profile when initialProfile omits one. Code asserting the profile has exactly the keys it passed will see one more.

  • Credential rows for password-reset and signup-flow state carry metadata.purpose instead of metadata.kind. There is no fallback reader: a token minted by the previous version resolves to no purpose and is refused as invalid. Both are 30-minute-TTL rows, so the exposure is one deployment window, and the failure direction is closed.

  • requestPasswordReset throws AUTH_MISCONFIGURED for a missing channel even when the address does not exist, and AUTH_PROVIDER_NOT_REGISTERED when the MFA provider is absent, on both branches.

Still open — F21's other half. The rate limit caps how fast identities appear; it does not stop one appearing for an address nobody proved they own, so account pre-emption and the duplicate-address refusal remain. The recommended fix — hold signup state in the credentials store and create the identity at completeSignUp — cannot be built, because fk_auth_credentials_identity requires the row that fix defers, and Provider.Context carries no other store that could hold it. That makes it a schema decision rather than a flow one. Both halves are pinned as FINDING: tests.

  • The fifteen medium and low findings left open in core/flows — and one critical that closing them turned up.

    The MFA gate on completePasswordReset never checked whose session it was handed.

    if (await hasTotp(row.identityId)) {
      const s = await sessions.getBySid(input.currentSid);
      if (!s || s.aal < 2 || !s.fresh)
        throw new AuthError("AUTH_RECOVERY_REQUIRES_MFA");
    }
    

    Nothing bound s.identityId to row.identityId. An attacker holding a victim's reset token — which is the exact situation the MFA requirement exists for — passed their own fresh AAL 2 session and the gate opened. Any account with a second factor was resettable by anyone with a second factor of their own, on any account.

    The session is now resolved once and only counts when it belongs to the identity the token names. This was not in the audit; it sat three lines from a finding that was, and was missed because a step-up check reads as being about the caller. Here the caller and the subject are different people by construction.

    releaseImpersonation logged the operator out instead of ending the impersonation.

    It revoked the impersonation session and called transport.revoke(), which clears the bearer outright. The real session that impersonate-start deliberately keeps alive is no help — impersonate overwrote the cookie with the impersonation sid, so its plaintext is gone from the client and nothing can present it again.

    It now routes through the impersonate-release rotation purpose, which had sat unused in the matrix since the matrix was written with exactly the semantics needed: mint, then delete the sid that was presented. The operator gets a session of their own back.

    Breaking: the return type is now { session, sid, intents } and carries an issue intent rather than a revoke. session is null and sid is '' only when the operator's own identity is gone, in which case the bearer is cleared as before.

    Impersonation sessions no longer claim the operator's authentication.

    aal and factors describe what a session's subject did to prove they are there, and the subject of an impersonation session is the target, who did nothing. Copying the admin's verbatim wrote the admin's TOTP, at the admin's completedAt, onto the target's session — so any policy asking "has this user recently passed a second factor" got the wrong person's answer for as long as the impersonation lasted.

    Both the impersonation session and the session releaseImpersonation hands back start at aal: 1 with no factors. The operator's assurance is not discarded, it is spent: it is the input to authorize(realSession, targetIdentityId), which is where an operator-AAL requirement belongs. Behavioural — hosts that read aal off an impersonation session will now see 1, and an operator returning to privileged work steps up again.

    linkProvider now requires an authorize callback.

    providerSub arrives as a plain string. Nothing about it is verifiable from inside duck-auth. Wired to a completed OAuth callback it is exactly right; wired to a route that trusts a request body, an attacker links their own provider account to a victim's identity and signs in as them from then on. The signature left that difference unstated, so both readings looked like correct usage.

    await auth.flows.linkProvider({
      identityId,
      providerId: "authGoogle",
      providerSub: claims.sub,
      authorize: async ({ identity, providerId, providerSub }) =>
        /* your check */ true,
    });
    

    Breaking. A missing callback is AUTH_MISCONFIGURED; a refusal is AUTH_PROVIDER_FAILED and writes nothing. Same shape impersonate and cancelAccountDeletion already use for the same class of problem, and the same warning applies: never async () => true.

    Unlinking a provider is audited, and can no longer race itself into a lockout.

    identity.linked existed; identity.unlinked did not. Removing an authentication factor — the write an account takeover performs to close the real owner's way back in — left no trace at all. The new event carries allowedLockout so an override is visible in the trail, and is registered for webhooks and audit envelopes.

    The lockout guard was also a read followed by an unversioned write. Two concurrent unlinks of different providers each saw the other's link still present, each concluded a factor would survive, and both landed — an identity with no way back in, produced by a guard whose entire job was to prevent that. The question is now re-asked against the post-write row, and the loser puts its link back with the original addedAt. Both racers rolling back is possible and is the safe direction.

    completePasswordReset ends sessions before it writes the password, and answers with intents.

    The old order left a window with the new password live and the old sessions still open: if the revoke threw, whoever held a session the reset was meant to end kept it, and the account holder had no way to tell. Failing before the password write is the harmless direction — nothing has changed, and the user asks for another link.

    A caller who is signed in now rotates through rotateOrCreate({ purpose: 'credential-change' }) rather than merely being swept, which puts the transition back on the single rotation path sessions.ts promises every privilege change takes. The ordinary email-link reset still sweeps: credential-change mints as well as revokes, and minting there would turn a reset link into a way to sign in.

    Breaking: the return type is now { ok: true; intents: Provider.Intent[] }. intents is empty on the email-link path and carries a replacement bearer when the caller was signed in.

    A failed MFA gate is also bounded now. Consuming the token outright would break the documented flow — being refused, stepping up, and calling again with the same token is how it is meant to work — so the gate is rate limited per token hash instead, and when the bucket is spent so is the token.

    The password-reset row no longer duplicates the address.

    metadata carried { purpose, email }. Nothing read the address back — the flow resolves the identity from row.identityId — and it was a second copy of PII in a table identities.erase has no reason to sweep, so an erased account left its email sitting there until the row's TTL expired.

    Signup state is bounded, atomic, and written through the facet.

  • profileMaxBytes now applies to flow.data at beginSignUp and advanceSignUp, not only when it finally reaches an identity row. Flow state lives in credential metadata, which has no size limit of its own, for up to 24 hours and is re-read on every stage — a cap the profile only meets on its last hop is not a cap.

  • advanceSignUp was rotate → revoke → upsert: three writes, no transaction, and the middle one destroys the token. A failure after the revoke stranded the user mid-signup, and the recovery depended on upsert re-accepting a secret hash that had just been revoked. It is now rotate (with the row's own secret, so the compare-and-set survives without invalidating the token) then patchMetadata, one atomic merge.

  • completeSignUp writes through identities.updateProfile instead of the raw store, which also deleted a hand-rolled merge and its as Profile.

  • The rotation is purpose: 'sign-up', a new entry in the matrix. previousSid is optional and most signups have no prior session, so calling every one of them a guest-promotion made the rotation log describe a transition that never happened.

Email verification.

  • IdentitiesImpl.markEmailVerified reads its own expected version and retries once on AUTH_STALE_WRITE. The flow consumes the single-use token before this write, so a concurrent profile update used to surface a raw stale-write error with the token already spent and no second attempt for the user to make.
  • The rate limiter moved below the two early returns that send nothing. An unknown id and an already-verified address used to spend a real user's resend budget on a request that could never produce a message.

Also: IdentitiesImpl.assertProfileWithinCap is public (and takes unknown) so callers staging profile data elsewhere can hold it to the same limit; provider-link.flow.ts builds one Provider.Context per call instead of two or three.

Every fix is pinned in src/core/flows/__tests__/flows-c6-open-findings.test.ts, each written to fail against the pre-fix code, and four of them again on real Postgres in flows.e2e.test.ts.

  • b06c50b: Expired sessions are no longer accepted at any privileged gate.

    SessionsImpl.getBySid hashed the sid and returned whatever the store had — no expiry check at all. resolveBySid, in the same file, checks both deadlines and deletes the row. Two reads that look alike, one of them unsafe, and four call sites depended on the unsafe one:

  • completeStepUp — an expired sid completed a step-up, and rotateOrCreate minted a brand new live session from the dead one. Presenting an expired sid did not merely pass the gate; it resurrected the session.

  • impersonate — an expired admin session could start an impersonation, and the app's own authorize(real, target) callback was handed that dead session to judge.

  • completePasswordReset — a stale session with aal >= 2 satisfied the MFA gate, so a valid reset token plus a long-dead session changed the password with no live MFA behind it.

  • The Redis store serving rows past expiresAt was correct layering — expiry is the facet's policy, not the store's — but this is what made it reachable.

getBySid now refuses a session past either deadline and deletes the row, the same side effect resolveBySid has. Folded into the read rather than patched at each call site, so the fifth caller added later cannot reintroduce it.

fresh is computed, not read. It was a persisted boolean that only touch() ever refreshed, so a session written fresh: true and never touched still claimed freshness weeks later — and the password-reset gate reads exactly that field. getBySid now derives it from rotatedAt and freshnessMs.

Also: the date narrowing copy-pasted across getBySid, touch and resolveBySid is one isSessionExpired helper now, which removed the four as number casts those copies carried. It fails closed on a non-finite or missing deadline, because NaN < now is false — a lenient read keeps a should-be-dead session alive forever.

Breaking:

  • getBySid returns null for an expired session instead of the row, and deletes it. Code relying on it to read expired rows must go to Sessions.Store.getByHash directly.
  • The fresh field on a session returned by getBySid is now computed from rotatedAt. A session that was stored fresh: true but rotated longer ago than freshnessMs now reports false — which is what the gates guarding password changes and step-up were always meant to see.
  • New exports isSessionExpired and isSessionFresh from ~/core/sessions.

resolveBySid recomputes it too. It takes an optional freshnessMs on its options bag, defaulting to DEFAULT_SESSION_CONFIG.freshnessMs, and AuthEngine.resolveSession passes its own — so the cookie path now means the same thing by session.fresh that the JWT path has always meant, which recomputes from rotatedAt on every verify.

And fresh turned out to be two claims in one column. Recomputing purely from the clock broke a step-up case, and the failure was correct: rotateOrCreate({ purpose: 'step-up' }) demotes the session being stepped up from by writing fresh: false onto a row whose rotatedAt is seconds old. The clock half decays; the stored half revokes. isSessionFresh is now the AND of both — a stored false is sticky, so freshness expires with time and can be withdrawn early, but storage can never grant it.

  • strict() demanded a lockout handler for an event nothing in the library emitted.

    engine.strict.ts refuses to boot production unless something is subscribed to

lockout. Nothing ever published one. Eight limiter guards — password sign-in, magic-link, api-key, signup, password reset (request and MFA gate), email verification, account deletion — each computed a Retry-After, threw AUTH_RATE_LIMITED, and told nobody. telemetry/otel counts lockout and webhooks forwards it; both had been waiting on a publisher since they were written. Operators were made to wire a handler for an event that could not arrive, which is worse than no check at all: it reads as coverage.

All eight now route through one helper, refuseRateLimited, which emits and then throws. A refusal that knows whose account is under pressure names it; one that does not stays silent, because lockout carries { identityId, until } and an emit with no subject is a page nobody can act on.

Emits: password sign-in, requestEmailVerification, requestAccountDeletion, and the completePasswordReset MFA gate.

Stays silent, deliberately: api-key (the only route from a token-hash bucket to a subject is the verification the guard exists to shed), magic-link and requestPasswordReset (resolving the address means running host code on every refused request to an unauthenticated endpoint, and a spent mail bucket blocks a delivery rather than an authentication), and beginSignUp (no account exists behind the address yet).

Two ordering changes came with it:

  • authPassword.complete now looks the identity up before consuming the limiter. This is the bucket lockout exists for, and it is the one site that pays for its subject — a refused request costs one indexed read the limiter used to shed, still a fraction of the argon2 verification the guard is actually there to stop. The happy path is unchanged; it made the same call one line later.
  • requestAccountDeletion consumes the limiter below the identity lookup, the same reorder requestEmailVerification already had. Above it, an id with no row behind it spent a bucket and would now have paged an operator about an account that does not exist.

Two bugs fell out of collapsing eight copies into one:

  • Retry-After: 0. Seven of the eight used Math.max(0, …), which tells a client to retry immediately for the last fraction of a second of a window — from the guard whose entire purpose is to say do not. The floor is 1 everywhere now, the value api-key alone already used.

  • A broken resetAt defeated the limit. Every copy called limited.resetAt.getTime() unguarded, so a limiter adapter returning a number raised a TypeError and the caller saw a 500 instead of a 429 — the limit did not apply. An invalid Date produced retryAfter: NaN. The helper reads it defensively and still refuses.

  • authRefreshoauthToken requires the identities probe instead of asking nicely for it.

    It was optional, documented as "should be supplied". Nothing in the library calls this

function — refresh-token rotation is invoked by the host directly — so there was no wiring to supply it, and the only guidance a caller got was a signature saying the safe argument was opt-in. Omitted, a refresh mints new tokens for an identity that has since been deleted and hands its id back, because nothing on the credential-first path looks at the identity.

apiKeyProvider passes stores.identities unconditionally for the identical check. This makes the same guarantee unskippable. A client-credentials grant, which has no row.identityId, still skips the lookup.

  • beginPasskeyRegistration tells the authenticator which keys the identity already has.

    excludeCredentials — WebAuthn's mechanism for "this account is already enrolled on this

authenticator, do not mint a second key" — was declared on Passkey.RegistrationOptionsInput and on Passkey.RegistrationOptions from the start, and never once populated. Every registration ceremony went out with the field absent, so a user who ran registration twice on the same device got two credentials for the same account with nothing to tell them apart: same name, same device, and no way to know which one to prune.

Breaking: beginPasskeyRegistration's second argument now requires credentialStore and tenant, matching completePasskeyRegistration, which already took both. That is what the ceremony needs to read the identity's live passkeys.

Revoked rows are deliberately left out of the exclusion list: a revoked passkey is one the user asked to be rid of, and re-enrolling that authenticator is the documented way back in. Transports are sent when the authenticator reported them and omitted rather than guessed when it did not.

Found by sweeping for option keys that a type declares and no implementation ever reads — the same sweep that turned up onFederationConflict.

  • 11394fc: The public surface now names what it returns, and a post-commit drain no longer reports a committed write as a failure.

    pending.flush() never rejects. It resolves with { published, failed: Error[] }.

    flush runs after the caller's transaction has committed and empties the buffer whether or not a listener threw, so a rejection asked the caller to handle a failure with nothing left to retry — and the natural handling, letting it propagate, answered a committed write with a 500 whose one promise, that the write did not happen, was false. The write happened; some announcement of it did not. Those are different facts and callers need to tell them apart, log the second, and still answer 200. A thrown non-Error arrives as an Error with the original on cause, so failed is always loggable.

    Callers who want the old behaviour can write if (failed.length) throw new AggregateError(failed). The reverse was not available, which is why this changed.

    identities.restore() returns Me | null. One rule now covers all three lifecycle writes: null means the id matched nothing — the same outcome softDelete and erase already reported that way — and a throw means a row was matched and a named rule refused it (AUTH_GRACE_EXPIRED, AUTH_EMAIL_TAKEN). restore was typed as though it could not miss, which told you which outcome the author remembered rather than which outcomes exist. It threw AUTH_UNAUTHENTICATED for an absent id; that path is now null.

    flows.cancelAccountDeletion is unchanged and still throws AUTH_UNAUTHENTICATED: at a flow boundary there is no account whose deletion could be cancelled, which is an error rather than data.

    The root barrel re-exports the domain types. @gentleduck/auth exported the engine and none of the types the engine returns, so naming the type of any return value meant a second import from @gentleduck/auth/core. Identities, Sessions, Credential, Org, Events, Batch, Pending, Flows, Engine, Bound, Provider, Transport, TenantContext, Envelope, Anomaly, Compliance, Hijack, M2m, Operations, Kms and DataAtRest are now reachable from the root. These are types only — the runtime surface is unchanged and still deliberately narrow.

    Identities is exported under its own name, and Identity is gone. @gentleduck/auth/core exported the namespace as Identity, so it read as Identities in every declaration, doc comment, internal signature and error message, and as Identity at the import site. There is now one name. Replace import type { Identity } from '@gentleduck/auth/core' with Identities; the members are unchanged, so the rest of each reference stays as it is.

    CHANGELOG.md ships in the package. The README links it and files did not include it, so on npm the link 404'd and the only way to see what a release contained was to diff two tarballs. @gentleduck/iam had the same gap and is fixed too.

  • Backup codes no longer wipe the user's password reset, and no other recovery token is a second factor.

    Credential.kind = 'recovery' names six unrelated things — a password-reset token, a

verification mail, a pending account deletion, an in-flight signup, an MFA backup code and a remembered device — and kind distinguishes none of them from each other. metadata.purpose is the real discriminator. The flows had already learned this; both backup-code implementations had not.

Deletes. BackupCodesFacet.generate, BackupCodesFacet.revokeAll and MfaImpl.regenerateBackupCodes each called deleteByKind(identityId, 'recovery'), which reads as "replace the codes I own" and means "delete all six". Regenerating backup codes therefore voided whatever the identity was holding: the reset link sitting in their inbox, their verification mail, their pending deletion request, their half-finished signup, and every device they had asked the site to remember. All three now delete by purpose.

Reads. BackupCodesFacet.verify and MfaImpl.verifyBackupCode hashed the submitted code and compared it against every recovery row, so the other five token families were candidate second factors. Not practically reachable — both verifiers case-fold before hashing (toUpperCase and toLowerCase respectively) while the other five store the hash of a raw base64url token, so a collision needs a token with no letter of the wrong case — but the guard was case-folding luck, not a check. Both now filter on purpose. BackupCodesFacet.remaining counted the same six families, so "you have N backup codes left" counted reset tokens.

Breaking. Backup-code rows written before this release carry no metadata.purpose and will not verify. Have affected users regenerate their codes. MfaImpl.regenerateBackupCodes and BackupCodesFacet.generate both mint a fresh, correctly stamped set.

New: RECOVERY_PURPOSES (the six names, in one place) and deleteCredentialsByPurpose(store, identityId, kind, purpose, ctx), which is the list-and-delete requestEmailVerification and requestAccountDeletion were each doing inline. Both now call it.

The two backup-code implementations remain independent and mutually incompatible — MfaImpl hashes code.toLowerCase(), BackupCodesFacet hashes an upper-cased, hyphenated form — so codes minted by one still cannot be verified by the other. Sharing a purpose does not make them share a format; that is a separate change.

  • 01eda74: The Redis session store: a parser that threw instead of failing closed, a create that could leave a session no revocation could reach, and a gc that never took the lease its contract promised.

    parseStoredSession never throws now — and stops losing factors quietly.

    Its whole contract is that a corrupt or tampered row reads as "no session" rather than taking a request down. It did not hold. Array.isArray narrowed factors to any[], so the filter read .method off whatever was in the array; a single null element threw, outside the try/catch that guards only JSON.parse. getByHash is on the path of every authed request for that session, so one bad blob was a permanent 500 — and listByIdentity, which parses in a loop, went down with it.

    Every other malformed shape was the quieter half and the more dangerous one: it was dropped. A row claiming aal: 2 came back carrying no factors at all, and step-up logic reads that list as authoritative.

  • A structurally broken entry — null, a primitive, no method — now rejects the whole row. That is corruption, and a session we cannot read is not a session we should serve.

  • An unknown but well-formed method is still skipped, not rejected: that is what a newer writer adding a factor method produces, and an older reader must not choke on it.

  • The 16-element factors cap that sessions.create and parseJwtPayload both apply is now enforced here too. This reader was the one door in that had no cap.

  • A malformed actingAs envelope is refused rather than degraded to null. Degrading it lost both halves of what the envelope is for — the audit trail naming the real actor, and the expiry bounding the impersonation window — and left the row reading as an ordinary session belonging to the person being impersonated.

create writes the index before the record. The two writes are separate round-trips. A failure between them used to leave a session that authenticates fine but sits in no identity index: listByIdentity cannot see it and deleteAllForIdentity cannot delete it, so it survives the password change or ban that was supposed to end it. Reversing the order alone is not enough and would have made things worse — a concurrent listByIdentity pruned the entry inside the gap, and a record no index names is unreachable forever. So listByIdentity no longer prunes: a missing record may be a create still in flight, and only that create knows. A failed record write compensates its index entry, but only when sadd reports it actually added one — otherwise a duplicate create would unindex whichever session won the race.

gc is driven by an expiry index instead of walking every session. The sweep used to SCAN every {prefix}:idx:identity:* key, read every member's record, parse it, and compare two dates — O(every session that exists) per cycle, on a schedule, with a get and a JSON.parse for each one. It also could not sweep guest sessions at all: they carry no identityId, sit in no index, and so were reachable only by their key TTL, which tracks absoluteExpiresAt and therefore never enforced the sliding expiresAt on them.

There is now a single deployment-wide sorted set, {prefix}:exp, whose members are sessionId:identityId scored by whichever deadline comes first. gc pages it with ZRANGEBYSCORE -inf <now> LIMIT, so a cycle costs one range query plus the rows that are genuinely due — nothing proportional to the sessions that are still live. Because the member carries the owning identity, a row is deleted and dropped from its identity index without its body ever being read, and a guest session is swept like any other.

The write paths keep the index in step: create adds the member after the record lands (an nx collision must not re-score the live session already under that id, and a create that cannot be scheduled unwinds itself), update re-scores before writing so a renewal is never swept on its old deadline and moves the member when the session changes identity, and delete / deleteAllForIdentity retire members directly rather than leaving them to come due.

This also closes the race the index walk could only narrow. That sweep met index entries naming records that had not landed yet and had to re-read to guess whether they were orphans; a session earns an expiry member only once its record exists, so a create in flight is invisible to gc by construction. The confirming second read is gone with it. create now refuses a session id containing :, since that is where the member splits.

gc takes a distributed lease. Sessions.Store.gc has been documented as "acquires distributed lease before running" since 5.x, and no implementation acquired anything — every instance in a fleet swept concurrently. Worse than the waste, an operator reading that line would reasonably conclude they did not need their own locking. RedisSessionImpl now takes {prefix}:gc:lease with SET NX and returns { deleted: 0 } when it loses. The lease is left to expire rather than released in a finally: a sweep that outruns the window has already lost it, and deleting it then frees a lease the run no longer holds. New gcLeaseSec config, default 300s. The SQL store's gc is unaudited and was left alone; the contract's docstring now says what it actually guarantees instead of over-promising.

_ttlFor fails closed. It assumed anything that was not a Date was a number, so an absoluteExpiresAt that arrived as an ISO string — what a JSON round-trip produces — made every step downstream NaN, and { ex: NaN } reached the client. Some clients write that as a key with no expiry at all: an immortal session. It now parses through the same parseStoredDate the read path uses, so a serialised date yields the session's real TTL rather than the 30-day ceiling, and only a genuinely unparseable value falls back to the cap.

listByIdentity reads concurrently. It awaited one get per session id in a loop. It backs the active-devices view and runs inside revokeAllForIdentity, so those were N sequential round-trips on a request path. Now Promise.all over the same get — no new client methods, since widening RedisLike.Client would force every adapter to implement more.

Also fixed here:

  • The parser's kind and aal gates were written with as casts; they are isSessionKind / isAal predicates now, along with isRecord and isFactorMethod for the new checks.
  • A session-security e2e case read the clock four separate times to build one row, so absoluteExpiresAt and expiresAt could straddle a millisecond tick and invert — tripping chk_auth_sessions_absolute_expires_after_expires and reporting a working constraint as a broken write. Both such cases now take a single reading.
  • The identity compliance suite gave restoreMany a one-second grace window and then spent it on a create-and-erase round-trip against the real database. On a loaded MySQL container the window closed first, and correct behaviour was reported as a failure. The round-trip now happens before the clock starts, on the minute-long window the rest of the suite uses.

Breaking:

  • Stored session rows that are structurally malformed — a broken factors entry, more than 16 factors, a partial actingAs — now resolve as null instead of resolving with the bad part silently removed. Sessions affected by this were already carrying data the library could not vouch for; they will need to sign in again.

  • Sessions.Store.gc implementations that can run on more than one instance are now required to serialise themselves. Nothing enforced this before because nothing did it.

  • RedisLike.Client gains zadd, zrem and zrangebyscore. A custom client implementing this interface by hand will not compile until it has them; FakeRedis and the bundled valkey/ioredis adapter already do. @upstash/redis and node-redis expose all three under those names.

  • RedisSessionImpl claims a new key, {prefix}:exp. A deployment that namespaces by prefix is unaffected; one sharing a database with other data should confirm that key is free.

  • Sessions created before this release have no expiry member, so gc will not sweep them — they fall back to their key TTL, which is what bounded them before this index existed, and their identity-index entries are cleared when that index key's own TTL lapses. They are otherwise fully usable, and any update schedules them. To sweep them promptly instead, backfill from the existing records once after deploying, or accept that they age out.

  • RedisSessionImpl.create now rejects a session.id containing : with AUTH_MISCONFIGURED. The sha-256 hashes the library generates never contain one; a deployment substituting its own id scheme must not use that character.

  • Sessions can be read and revoked per tenant. They were the only tenant-scoped store with no tenant parameter.

    Every store meant to be tenant-scoped takes a TenantContext on every method —

Credential.Store on all ten, Org.Store on all six. Identities.Store takes none and is deliberately global; the conformance suite says "identities are global" in as many words. Sessions sat between the two: a tenantId column that no method could select on, against a TenantContext docstring promising "stores receive it on every call".

Because identities are global, one person is routinely a user of tenant A and of tenant B with every session hanging off one id. So listByIdentity(identityId) handed tenant A the IP, user-agent and existence of that person's tenant B sessions, and deleteAllForIdentity(identityId) — "sign out everywhere" — logged them out of tenant B when tenant A revoked. resolveSession(req, { expectedTenantId }) does not cover it: it compares after the read and the option is optional, and core/tenant/tenant.ts says outright that "withTenant is a default, not a fence".

Sessions.Store.listByIdentity and deleteAllForIdentity now take an optional ctx: TenantContext, as do SessionsImpl.listForIdentity and SessionsImpl.revokeAllForIdentity. Omitted, or carrying no tenantId, it means every tenant, so every existing caller keeps its current answer. Named, it matches exactly — which puts a global (tenantId: null) session outside a named tenant's scope, the same rule Credential.Store already follows.

Implemented in all three stores. SqlBridge.Session.listByIdentity and deleteAllForIdentity take tenantId: string | undefined, mirroring the credential bridge — a breaking change for anyone who implements the SQL bridge directly; the shipped pg, sqlite and mysql adapters are updated. Two cases were added to runSessionStoreCompliance, so all five shipped stores are held to the rule and the pg, mysql, redis and valkey runs prove it against real servers.

Still global, deliberately: the credential-change sweep in rotateOrCreate (a password belongs to the global identity, so changing it must end every session it could have opened), revokeAllForIdentities (scoping the loop fallback but not the optional set-based store form would make one call behave differently per adapter), and identities.erase.

Also fixed: FakeRedis.del ignored sets and sorted sets. It deleted from its string map only, so dropping a set or zset key was a silent no-op — and the session store's index and expiry keys are exactly those types. runSessionStoreCompliance runs the whole store contract against FakeRedis in-process precisely to catch divergences, so any case asserting "the index key is gone" had been passing against a fake that never removed it.

The Redis scoped delete srems the ids it removed rather than deling the index key: dropping the key would leave the sessions it spared alive and unreachable by every later read and sweep — signed in, with nothing able to sign them out.

  • beginSignUp no longer lets anyone permanently claim someone else's address.

    It writes an auth_identities row for an address the caller has not proved they own.

The unique index on email then means the real owner's signup collides with the squat and there is nothing they can do about it, ever — and the duplicate-email refusal answered "is this an account?" to anyone who asked.

Deferring the identity until the address is verified — the obvious fix — is not buildable: fk_auth_credentials_identity is a NOT NULL foreign key to the very row that plan defers, so the credential holding the flow token has nowhere to live, and Provider.Context carries no other store that could host it.

So the squat is disarmed rather than prevented. beginSignUp reclaims an existing row when, and only when, it is in exactly the state beginSignUp itself leaves behind: emailVerified false, no provider links, and no credential that is not a signup-flow token or already revoked. The previous flow's tokens are revoked, the profile becomes the new signup's, and the caller gets the row. An attacker parking on victim@corp.com no longer blocks its owner — the owner takes the parking spot.

The predicate is the safety argument, not a detail. completeSignUp mints a session for the reclaimed identity, so a rule one notch looser would be account takeover rather than squat reclaim: an unverified account with a password set is somebody's, and so is one with a Google link or a TOTP secret. MFA backup codes are kind: 'recovery' under purpose: 'mfa-backup-code', so they fail the signup-flow test and read as established, which is right. The credential check is deliberately unscoped — identities are global while credentials are not, so a tenant-scoped read would miss a password in another tenant and call an established account abandoned.

completeSignUp now records the verification the flow claimed. Nothing in this flow ever wrote emailVerified, so an account created through the documented happy path stayed unverified for good. Harmless before; under the reclaim rule it would have left finished accounts reclaimable forever. When the email-verified stage is among the completed ones, the column is written, and the session is issued from the settled row.

Still open, deliberately. The identity row is still written on an unauthenticated request — bounded by the signup:begin:<address> limiter, and no longer a claim. And an established account still answers AUTH_EMAIL_TAKEN, because the address is unique and a second account for it cannot exist. Closing that needs what requestPasswordReset has — a channel to answer through, so the owner is mailed and every caller gets the same reply — and beginSignUp takes no channel.

  • completeStepUp reads the second factor in the session's tenant, not in one the caller names.

    It took an optional tenantId that scoped the MFA credential read, while the session being

stepped up carried a tenant of its own — and nothing bound the two. The default when tenantId was omitted made it worse: {}, an unscoped read. Credentials are tenant-scoped and identities are not, so a TOTP secret or backup code enrolled in tenant B satisfied a step-up for a session in tenant A — a tenant that had never seen a factor for that identity, and whose hasTotp therefore never asked for one. Any policy gating admin work on aal >= 2 accepted a factor its tenant had never issued.

Breaking: completeStepUp no longer accepts tenantId. The factor is looked up in session.tenantId (unscoped for a global session, matching every other credential read), so there is no second input left to disagree with the session.

Found by sweeping for the shape of F28 — a gate that resolves its scope or its subject from one input and acts on another, with nothing binding them. It was the only other live instance; impersonate, signIn, linkProvider and releaseImpersonation each bind their two inputs already.

  • onFederationConflict is reachable from the six shipped OAuth providers.

    The policy that decides what happens when an OAuth profile's email already belongs to a

local identity with no link to that provider — 'reject', 'link-if-verified', or a caller-supplied (ctx) => 'link' | 'reject' hook — was declared on OAuth.Options, read by OProviderImpl, and documented in full. It was also unreachable: OptionsBase, the type every shipped provider factory takes, never declared the key, and oProvider is not exported from any package entrypoint. So google(), github(), linkedin(), microsoft(), discord() and apple() were all hard-wired to 'reject' with no way for a consumer to choose otherwise.

'reject' is the safe direction, which is why nothing broke and nobody noticed — it made the feature dead rather than dangerous. OptionsBase now carries the key and all six factories forward it, so "merge after out-of-band confirmation" and "link when the IdP says the address is verified" are configurable rather than documentation for a branch no caller could reach. The default is unchanged.

The policy branch also had no test of any kind; it now has five, driven through google() against a stubbed IdP.

Patch Changes

  • The client hands back real Dates, and revoke() says a key is revoked.

    Sessions.Me and Identities.Me declare Date for every deadline they carry. The vanilla

client's getSession() did a bare JSON.parse of the response and handed the result back under those types, so every one of those fields was an ISO string wearing a Date annotation. The loud half was session.expiresAt.getTime is not a function. The quiet half was worse: session.expiresAt > new Date() compares a string against a Date, is always false, and a live session reads as expired with nothing thrown. All four framework clients (react, vue, svelte, solid) wrap the vanilla one, so all four had it.

Revival happens once, in getSession, over a named list of fields — not by sniffing values for date-shaped strings, which would rewrite identity.profile, whose contents belong to the host app. A string that will not parse is left exactly as it arrived rather than promoted to an Invalid Date, which passes instanceof Date and compares false against everything.

ApiKeysFacet.revoke() documents that it answers with "the key as it stands revoked". The projection behind it copied createdAt, lastUsedAt and expiresAt and dropped revokedAt, so ApiKeys.ApiKey.revokedAt was declared by the package and never once populated — a UI reading the answer back could not tell the revoked key from a live one.

The three store-compliance suites now assert, on every read path, that each field is the type its row declares — including that a Date is a Date and not an Invalid one. That runs against every shipped backend: memory, drizzle sqlite/postgres/mysql, redis, valkey.

  • Updated dependencies [70a9e4d]

  • Updated dependencies [ff6f112]

  • Updated dependencies [3de4298]

  • Updated dependencies [8c131a4]

  • Updated dependencies [3de4298]

    • @gentleduck/iam@5.8.1

5.4.1

Patch Changes

  • 11394fc: Adapter audit: batch outcomes name the rule that refused a row, and a soft-deleted account no longer holds a provider login hostage.

    Batch.FailureReason gained grace-expired and email-taken. not-found was reported for every row a restoreMany did not apply, including rows that were found and then refused by the grace window or by an address a live identity had taken since. A caller told not-found has no way to learn the id is still there and still restorable once the clash is resolved. Both batch paths were affected — the set-based SQL form and the loop fallback used by the memory and Redis stores. Consumers matching exhaustively on FailureReason will need the two new arms.

    A soft-deleted row no longer holds its provider sub. The cross-identity uniqueness guard counted hidden rows, but findByProviderSub ignores them, so a sub could be unreadable and unclaimable at the same time — a deleted account keeping someone's Google login forever. All four adapters now count live rows only, matching what the read side already did.

    restoreManyReturning (SQL bridge) returns { candidates, restored }. Custom SqlBridge implementations need updating; the dialects shipped with the package already do. The candidate rows are what let the store report why a row did not come back instead of guessing.

    isRestorable is exported from @gentleduck/auth/adapters/sql, so the batch and single-row paths cannot drift about when a grace window has closed.

5.4.0

Minor Changes

  • The SQL adapters now hand back real Dates for the timestamps stored inside JSON columns.

    Identities.ProviderLink.addedAt, Sessions.Me.factors[].completedAt and both dates on Sessions.Me.actingAs live inside jsonb / json / text columns. JSON.stringify writes a Date as an ISO string and the driver hands that string straight back, so on pg, mysql and sqlite all three were typed Date but held a string at runtime. The memory and Redis stores return real Dates, so nothing in the library noticed — it surfaced in caller code as addedAt.getTime is not a function, and as actingAs.expiresAt < Date.now() comparing a string to a number, which is always false: an impersonation window that never looked expired.

    createSqlStores revives them at the boundary where bridge rows become library rows, so every dialect is covered at once.

  • An unreadable addedAt keeps the link and falls back to the row's createdAt. The date is informational, and dropping the entry would silently remove a way into the account.

  • An unreadable factors[].completedAt falls back the same way.

  • actingAs gets no fallback: a window whose start or end cannot be read is dropped, matching what the Redis session store already did.

Two new store-compliance tests hold memory, sqlite, pg and mysql to this, rather than only the adapter that happened to be exercised.

Anyone reading these fields on a SQL adapter was reading a string; on 5.3.6 and earlier the workaround was new Date(link.addedAt) at every call site, and those calls keep working unchanged.

5.3.6

Patch Changes

  • 80c809a: Deleting an identity now ends every way into it, and a restore no longer resurrects claims the account can no longer prove.

    flows.signIn re-reads the identity behind a startSession intent, so every sign-in provider was already covered. The credential-first surfaces were not: they resolve a credential row and hand back row.identityId without ever looking at the identity.

  • apiKeys.verify() refuses a key whose identity has been soft-deleted or erased, reporting AUTH_APIKEY_INVALID — the same code as an unknown key, so whether an id still exists is not something an unauthenticated caller can probe. M2MImpl.exchange() is covered by the same check; it previously minted a live bearer token for a deleted account. apiKeyProvider() wires the identity store in automatically, and the constructor argument is optional so a direct new ApiKeysFacet(...) keeps working.

  • authRefreshoauthToken accepts an optional identities probe and refuses to refresh for a deleted identity. The check runs before the CAS claim and before the provider exchange, so a dead refresh costs nothing and leaves the row intact for a later restore.

  • softDelete clears emailVerified. The unique indexes are partial on deletedAt and findByEmail filters the same way, so the address is genuinely free while the row is hidden; restoring must not hand back a verified claim to an address the identity may no longer control.

  • restore refuses once the grace window has closed, reporting AUTH_GRACE_EXPIRED. The SQL bridges previously cleared deletedAt unconditionally, bringing back accounts whose window had long since closed — including ones already queued for hard purge — while the memory adapter refused them. The dialects now agree with the memory adapter.

  • restore refuses when the address was claimed while the row was hidden, reporting AUTH_EMAIL_TAKEN instead of a raw unique-index driver error on SQL, or two live rows sharing an email on an adapter with no such index.

  • completePasswordReset refuses a token whose identity has been deleted, reporting AUTH_RECOVERY_TOKEN_INVALID so a reset link cannot double as a way to ask whether an account still exists. It previously rotated the token, wrote the new password and emitted recovery.password.completed for an account nobody could sign in to.

assertRestorable, assertEmailFree and profileEmail are exported from ~/adapters/sql for bridge authors implementing restore.

  • 45e1ff6: Every mutating call on the public API now answers with what it did.

    A write that returns void cannot be told apart from a write that matched nothing, and forces a second read for information the statement already had. Across the engine's surface, the mutating calls now return the row, the rows, or the count they touched — null / [] / 0 when nothing matched.

    Identities. softDelete, erase, link, unlink and merge return the row (restore already did). Where the dialect has RETURNING this is the same round trip; MySQL re-selects by primary key, as it already does for update and restore. erase answers with the row as it was immediately before deletion; merge with the survivor, already carrying the union of both provider lists.

    Sessions. revoke and revokeByHash return the session they ended — the row is already read to find it. revokeAllForIdentity returns the sessions it ended, so "you were signed out of 4 devices" needs no second query; that list was already read to emit one event per session.

    Credentials. The store's revoke and delete return the row, deleteByKind the rows. On top of that, apiKeys.revoke answers with the key it revoked, and mfa.removeTotp / removeWebauthnMfa answer { removed } — the count, not the rows, which carry the shared secret.

    Orgs. removeMember answers with the membership as it stands left, setRoles with the membership carrying its new roles — the sanitized set actually stored, not the one passed in.

    Flows. completeAccountDeletion, cancelAccountDeletion, completeEmailVerification, linkProvider and unlinkProvider carry the identity back as identity, alongside the fields they already returned. completeAccountDeletion's restorableUntil is now read off the deletedAt the store actually wrote rather than a second reading of the clock, so the deadline reported is the one restore is measured against.

    Operations, webhooks, pending, anomaly. operations.maintenance / readOnly return the resulting State. webhooks.deliverOne returns one Delivery per eligible endpoint — { endpointId, delivered, attempts, lastError? }. pending.flush returns { published } and discard { discarded }. anomaly.unregister returns whether it removed anything.

    Assertions (apiKeys.requireScopes, operations.assertOperationsForRoute, hijack.applyReaction), registrations (anomaly.register, providers.register) and plugins.dispose stay void: they throw or they do not, and a return value would be noise.

    Four silent failures fell out of making the returns honest:

  • merge wrote nothing and reported success when the survivor or the dup did not exist. On the SQL dialects it was worse than a no-op: the dup's credentials and sessions were re-pointed at an identity that was not there and the dup was then deleted. The memory adapter had always refused this; every dialect now agrees, and IdentitiesFacet.merge checks the survivor before any of it runs, reporting AUTH_UNAUTHENTICATED.

  • flows.completeAccountDeletion reported { identityId, restorableUntil } for a valid token whose identity had since been erased, promising a grace window over nothing. It now reports AUTH_RECOVERY_TOKEN_INVALID.

  • webhooks.deliverOne dropped a permanently failed delivery in silence when no dead-letter sink was configured — no log, no return value, nothing to distinguish it from one that landed. This was pinned as a finding; the per-endpoint outcome closes it.

  • identities.eraseMany's loop fallback reports not-found for an id that was not there, matching the set-based path, which always did.

Breaking for custom adapter authors. Identities.Store and SqlBridge.Identity type softDelete, erase, link/insertProviderLink, unlink/deleteProviderLink and merge as returning the row or null; Credential.Store and SqlBridge.Credential type revoke and delete the same way and deleteByKind as returning the rows; Org.Store types removeMember and setRoles as returning the membership or null. An adapter that returns void will not typecheck; return what you touched.

5.3.5

Patch Changes

  • 7a1ce88: AuthEngine.withTransaction(client) binds the whole public surface - reads included - to a transaction you own, and batch forms report per-row outcomes instead of collapsing to void.

    The client is opaque to the library and handed straight back to your adapter, so the engine never learns what driver you use. Nested calls inherit it: flows.completeAccountDeletion() reaches identities.softDelete, sessions.revokeAllForIdentity and credentials.delete, and all three land on your transaction. Reads are bound too, so a read inside the transaction sees its own uncommitted writes.

    Events do not fire inside a transaction. They buffer in pending and publish on flush(), so a rolled-back deletion never appears in the audit trail as a completed one. discard() drops the buffer, peek() inspects it without draining, and a listener that throws does not stop the drain - every buffered event is attempted and the call rejects with an AggregateError at the end.

    Also in this release:

  • identities gained softDeleteMany, restoreMany, eraseMany, updateProfileMany, linkMany and unlinkMany; sessions gained revokeAllForIdentities and revokeByHashes; the credential store gained deleteByIdentities. Each collapses to one statement per table where the adapter can express it and loops otherwise, so every adapter supports every batch form.

  • A hard failure - a constraint violation, a driver error - throws, which inside your transaction aborts the whole thing and is what makes a batch atomic with your own work. A soft failure - a lost optimistic-lock race, a row that was not there - is reported per row as stale-write, not-found or skipped without throwing.

  • Stores and the drizzle pg, mysql and sqlite bridges declare withClient. A store that cannot join a transaction makes withTransaction throw AUTH_MISCONFIGURED naming that store, rather than silently leaving those writes outside your transaction.

  • Guards - limiter, idempotency, hijack and anomaly - are deliberately not reachable on the bound view. An attacker who can force a rollback must not be able to refund the attempts it cost.

  • createSqlStores binds optional bridge methods to their bridge instead of destructuring them, so a bridge implemented as a class no longer loses its this.

5.3.4

Patch Changes

  • 8a13899: Drop deletedAt from authCredentials and authSessions, added in 5.3.3. Both tables' delete()/deleteByKind()/deleteAllForIdentity() are hard-delete by explicit design (retaining a revoked credential's secret forever, or a dead session row, is not a feature), so the column would never have been set by anything in this codebase - it was speculative, not wired to a real soft-delete path. authCredentials already tracks its own dead-state via the adapter-managed revokedAt; a second, adapter-inert "this row is gone" column duplicated that job without adding one. authIdentities.deletedAt is unaffected - its softDelete(id, gracePeriodMs) is a real, adapter-managed flow, unlike these two.

5.3.3

Patch Changes

  • fd6e8c2: Round out audit columns on the drizzle schema (pg/mysql/sqlite):

    • authCredentials/authSessions gain updatedBy and deletedAt (they already had updatedAt from a prior release) - both rows are genuinely mutated in place (rotate/patchMetadata/revoke, session update()), so tracking who/whether matters there.
    • authEvents drops the createdBy it briefly had - it's an append-only audit log, never updated, so the only actor-relevant fact is who triggered the event, which already lives in the event payload itself.

    Every read method (findById, listByIdentity, findByProviderSub,

findByHashedSecret on credentials; findByHash, listByIdentity on sessions) now filters deletedAt IS NULL, matching authIdentities. Nothing in this adapter sets deletedAt on these two tables (delete()/deleteByKind()/deleteAllForIdentity() still hard-delete, on purpose - turning them into soft-deletes would mean revoked credential secrets are retained forever, which is a regression, not a feature); the column is honored if something outside the adapter sets it.

  • 30a595d: Fix the vanilla client never sending the CSRF header, so every cookie-authenticated write (signOut included) failed the server's verifyCsrf check. createAuthClient now reads the CSRF cookie (__Host-duck-csrf by default) and echoes it on the configured header (x-csrf-token by default) for any non-safe method; safe methods (GET/HEAD/OPTIONS/TRACE) are left alone. Both names are configurable via csrfCookieName/csrfHeaderName on Cfg.

    Provider's props and client/react's types also gain the Profile generic they

were missing (IProviderProps had no type param, client was typed Client<any>), so a consumer's custom profile type now flows through instead of being erased.

5.3.2

Patch Changes

  • 95a4eb2: Add createdBy/updatedBy columns to the drizzle adapter's tables (authIdentities, authCredentials, authSessions, authEvents), matching the pattern @gentleduck/iam's drizzle schemas already use.

    Nullable, and never set by the adapter itself (it has no actor context) - set them from

triggers or direct admin writes. updatedBy only exists on authIdentities, the only table with an updatedAt column.

5.3.1

Patch Changes

  • 959a8a4: Add DB-level defaults for createdAt/updatedAt on the drizzle adapter's tables (authIdentities, authCredentials, authSessions, authEvents), matching the pattern @gentleduck/iam's drizzle schemas already use.

    The store layer already sets both fields explicitly on every insert and update, so this

changes no observable behavior through the adapter's own API. It backstops rows written outside that path (raw SQL, migrations, manual seeds) so created_at is never left null.

  • 959a8a4: Add a valkeyXxx wrapper next to every Redis-backed store, so switching between redis/valkey clients is a one-line change instead of hand-wiring valkeyAdapter into each store's config: valkeySessionImpl, valkeyDPoPNonceStore, valkeyEvents, valkeyIdempotency (@gentleduck/auth/core/idempotency), and valkeyLimiter (new @gentleduck/auth/limiters/valkey entry, mirroring limiters/redis).

    valkeyEvents takes a { cmd, sub } connection pair rather than one client: once

an ioredis/iovalkey connection calls .subscribe(), it enters subscriber mode and can no longer run ordinary commands (including PUBLISH), so the publish/command side and the subscribe side need separate connections. valkeyPubSubAdapter is exported standalone for callers who want to drive RedisEvents directly.

Each valkeyXxx factory lives in its own sibling file next to the matching RedisXxx implementation, mirroring the redis.ts/valkey.ts split used elsewhere: sessions.redis.ts/sessions.valkey.ts, dpop-nonce.redis.ts/ dpop-nonce.valkey.ts, events.redis.ts/events.valkey.ts, idempotency.redis.ts/idempotency.valkey.ts, and limiters/redis/ limiters/valkey. adapters/valkey is a re-export barrel, mirroring adapters/redis. Each also has its own real-server e2e suite colocated next to the matching redis*.e2e.test.ts (sessions.valkey.e2e, dpop-nonce.valkey.e2e, events.valkey.e2e, idempotency.valkey.e2e, valkey-limiter.e2e).

Also removes three independent hand-rolled copies of the ioredis-to-RedisLike translation that predated valkeyAdapter (test/e2e-redis.ts's toRedisLike, used across eleven e2e suites; events.redis.e2e.test.ts's eventsClient; and the revocation worker's toEventsClient), replacing all of them with valkeyAdapter/ valkeyPubSubAdapter so there is exactly one implementation of that translation.

5.3.0

Minor Changes

  • 39aaa82: Guard every server adapter's routes, and record the caller on the session.

    Elysia, Fastify and Koa ran sign-in, sign-out and provider-begin with no CSRF check,

while Next, Hono, Express and Nest guarded theirs. Which adapter an application mounted decided whether a cookie-authenticated POST could be driven from another origin, and nothing in the API hinted at the difference. All seven guard now.

Every adapter also gets a guard for the application's own routes. csrfGuard was exported the whole time but no adapter exposed it in its own middleware shape, so protecting a route outside the mounted set meant hand-rolling the framework glue:

  • app.use(expressCsrf(auth))
  • app.use(koaCsrf(auth))
  • app.use('*', honoCsrf(auth))
  • fastify.addHook('preHandler', fastifyCsrf(auth))
  • app.onBeforeHandle(elysiaCsrf(auth))
  • export const POST = withNextCsrf(auth, handler), a wrapper because the App Router gives the adapter no chain to hook

They share Csrf.GuardOptions, and each writes its own 403 rather than delegating to an error handler the application may not have.

Sign-in dropped the caller's ip and user-agent in every adapter, so every session row recorded a device it could not name and the anomaly detectors had nothing to compare against. callerContext forwards only what the framework itself resolved: reading a forwarded header in the library would take the value the caller wrote, and the host is the only layer that knows how many proxies it trusts. Elysia and Hono resolve no address themselves, so their context types take an optional ip the application sets.

BREAKING: a cookie-authenticated POST to the elysia, fastify or koa sign-in, sign-out or provider-begin route now requires a CSRF token and gets a 403 without one. Bearer and JWT transports keep the existing bypass, since they carry auth in the Authorization header and are not sent ambiently by a browser.

  • 39aaa82: Configure idempotency and anomaly the way the limiter is configured.

    idempotency accepted only the facet, so the config line read

idempotency: idempotency(memoryIdempotency()) right next to limiter: redisLimiter({ redis, max, windowMs }). Two spellings for the same idea, and the wrapping was easy to forget.

The key now takes a bare store as well and the engine normalises it, and memoryIdempotency() / redisIdempotency() return a ready facet whose one config object carries both store knobs and facet knobs. The whole line is idempotency: redisIdempotency({ prefix: 'auth:idem', redis }). new MemoryIdempotency() and new RedisIdempotency() still give the bare store, and wrapping an already-wrapped facet is a no-op rather than an error.

MemoryIdempotency refused to construct unless development: true was passed, in every environment rather than only production, which made the no-arg constructor unusable including in the engine's own dev fallback. Only NODE_ENV=production is refused now, and development: true is the escape hatch for it.

anomaly had no config key at all: the engine hardcoded DEFAULT_ANOMALY_CONFIG, so the detectors could be registered but their thresholds and per-signal reactions could never be tuned. anomaly is merged over the defaults the same way hijack is.

createAuth silently dropped two keys its type accepted. plugins cannot work because installation is async and createAuth is not; oauth.stateSigningSecret cannot work because the secret has to reach each provider at construction. Both now throw AUTH_MISCONFIGURED naming the call that does work (await auth.use(plugin), and github({ stateSigningSecret })), rather than booting an engine that quietly lacks what was asked for.

  • 39aaa82: Record email verification on the column, not the profile.

    completeEmailVerification wrote emailVerified: true into the identity's profile, and

the OIDC OP read email_verified back out of it for the userinfo claim.

updateProfile merges a caller-supplied patch without filtering keys, so a verified-email flag living in the profile is something the account holder can set on themselves, and any relying party trusting the email_verified claim inherits that. The emailVerified column has been on the identity row the whole time.

Both sides read and write the column now, and beginSignUp stops seeding the profile flag.

BREAKING: emailVerified no longer appears in identity.profile. Read identity.emailVerified instead. Existing rows keep whatever their profile already holds; nothing reads it any more. If an application has been trusting profile.emailVerified, treat that value as unverified user input and reconcile it against the column before relying on it.

  • 39aaa82: Stamp an audit envelope onto every event that declares one.

    The engine already wrapped its bus in withAuditStamping, but the module it comes from

was never published, so the package did not build from source. It ships now.

Two sources fill the envelope, in priority order: the ambient one opened by runWithAuditEnvelope(), which an adapter wraps request handling in once the session is resolved, then the emitted session's own actingAs. A payload that already carries audit is left alone. Absent therefore means "no impersonation was in effect", not "unknown", but only for events emitted inside a runWithAuditEnvelope() scope or carrying a session.

The audited-event list is a total record over Events.AuditedEvent, so a new event declaring audit fails to compile until it is listed, rather than silently going unstamped.

session.rotated now carries previousSessionId, the hashed id of the session rotated away from, present whenever the caller supplied a previousSid. Audit consumers use it to chain a session's lineage across rotations.

authz.revoked is declared on the event map for the IAM side to publish and duck-auth to subscribe to, so every instance can drop cached decisions. It carries no envelope precisely because it does not originate here.

The wrap has to be explicit rather than happening inside resolveSession, because doing it there needs AsyncLocalStorage.enterWith, which segfaults on Bun 1.3.14-canary.

  • 39aaa82: Export the factories, and finish the barrels.

    The previous release gave every publicly constructed class a factory function, but

several were never exported, so new remained the only reachable spelling for AnomalyFacet, HijackFacet, WebhookDeliverer, MemoryPasskeyChallengeStore, AuthMemoryDeviceFingerprintStore, DPoPVerifier, the data-at-rest providers, the password hashers, and the api-key / magic-link / passkey / saml / passwords impls.

The channels barrel exported one type and nothing else, so every channel had to be imported by deep path. All six ship from @gentleduck/auth/channels now. The anomaly barrel likewise omitted both detectors and the fingerprint store, which meant the detectors could not be registered without reaching past it.

On the IAM side, iamEngine and iamLRUCache are exported alongside their classes.

BREAKING: three aliases in the @gentleduck/auth root now name the factory rather than the class, so new on them stops compiling.

  • AuthBackupCodesFacet is now backupCodesFacet; the class is BackupCodesFacet.
  • AuthInMemoryEvents is now inMemoryEvents; the class is InMemoryEvents.

Both classes are exported under their own names, so new BackupCodesFacet(...) and new InMemoryEvents() are the mechanical fix.

  • 39aaa82: Make OIDC authorization codes and refresh tokens single-use under concurrency.

    Both consumers read the row with a plain SELECT and then wrote. Under REPEATABLE READ a

SELECT is a snapshot read, so every concurrent transaction saw the row and, with nothing checking the write, every one of them returned it. An authorization code was redeemable as many times as it was presented at once, and two racers could both pass a refresh token's rotation-reuse detection, which is the mechanism that is supposed to catch a stolen token being replayed.

The write decides the winner now. consumeCode requires its DELETE to report exactly one affected row; consumeRefresh adds consumed_at IS NULL to the UPDATE and requires the same. A single affectedRows helper reads the count, since drizzle types a mutation result on a loosely-typed MySqlDatabase as unknown and these counts are precisely what make the operations single-use.

The consents index was declared non-unique, which broke the same operation two ways: upsert relies on ON DUPLICATE KEY (MySQL) and ON CONFLICT (Postgres), and neither fires without a unique key. On MySQL it appended a second consent row for the same (identity_id, client_id) instead of replacing the scope, leaving find to return whichever it reached first. On Postgres it raised 42P10 outright, so consent upsert did not work at all.

BREAKING: oidc_consents_id_client becomes a unique index on both dialects. Deduplicate existing rows per (identity_id, client_id), keeping the most recent granted_at, before applying the migration, or the index will refuse to build.

  • 39aaa82: Refuse a session whose identity was erased, on both resolution paths.

    resolveSession resolves a request two ways: a transport that can verify a token

statelessly, else a lookup by sid. Only the sid path refused an erased identity. The verify path did the same lookup and returned { session, identity: null }, which is truthy, so makeGuard passed it and a deleted user was authenticated.

The check now lives in finalize, which both paths already call, so neither can skip it and a third added later cannot either. resolveBySid keeps its own copy because it is exported and callable directly.

Reaching the weaker path was one line: jwtTransport and dpopTransport both define verify and are exported from the same module as cookieTransport and bearerTransport, so adding one to an existing compositeTransport array moved every request onto it with no other change and no test failing.

BREAKING: resolveSession now throws AUTH_SESSION_REVOKED on the verify path where it previously returned a result with a null identity. Callers treating a null return as "not signed in" will see the error instead, which is what the sid path already did.

Both paths also agree on a cross-tenant token now. The tenant comparison moved into resolveBySid, which is the only place it can run before the erased-identity throw, so a token for a foreign tenant looks absent on either path whether or not its identity still exists, and the identity is never looked up at all.

  • 39aaa82: Harden session creation, rotation and expiry.

    sessions.create validated that factors was an array of at most 16 but never what was

in it. The Redis reader drops an unlisted method and a non-Date completedAt on the way back out, so a malformed entry persisted a row that could not be read back intact. Each entry is now checked to be { method: FactorMethod, completedAt: Date }. fingerprint is header-derived like ip and userAgent and is now capped like them, at 256 characters.

rotateOrCreate handled credential-change after minting the replacement and then skipped that row by id while sweeping, which only held because the store had already been read. It sweeps the identity first and mints afterwards, so the ordering is not load-bearing. The purpose switch gained a never default: adding a purpose without deciding its revocation semantics is a build error rather than a silent no-op, which is how a new purpose would otherwise ship revoking nothing.

touch extended a session's sliding expiry without checking it first, so a session whose expiresAt had already passed, or was non-finite, was revived by the next request that touched it, even though resolveBySid would have rejected it. It deletes the row and fails closed.

session.created now carries the identity when the caller already holds it (sign-in, sign-up, impersonation), so a listener does not have to re-read the row it was just handed. createGuest and promoteGuest return the csrf token they were already minting, and promoteGuest forwards fingerprint and actingAs, so guest device-binding survives promotion.

BREAKING: sessions.create throws AUTH_MISCONFIGURED on a malformed factor entry it used to persist. Callers building factors by hand should confirm completedAt is a Date and not an ISO string.

  • 39aaa82: Make a TOTP code single-use within its validity window.

    verifyTotp answered only yes or no, which is not enough to satisfy NIST SP 800-63B's

requirement that a verifier accept a given OTP once per validity period. The drift window spans three steps, so a code observed once, over the user's shoulder or in a phished prompt, stayed valid for about ninety seconds. That window is the whole protection on a privileged step-up.

matchTotpStep returns which time step matched, scanning the full window without short-circuiting for the same reason verifyTotp does: stopping early leaks which step matched. The step is recorded on the enrollment as lastTotpStep, and a code matching that step or an earlier one is refused as a replay.

Confirmation spends its code too, so the code that enrolls a factor cannot immediately be replayed into a step-up on the enrollment it just created.

One consequence worth knowing: a user who legitimately needs two step-ups inside the same 30-second step must wait for the next code. That is the intended reading of the requirement.

Patch Changes

  • 39aaa82: Fix three defects in the drizzle bridges.

    Postgres: two @> containment predicates ended in ]::jsonb, a stray bracket that made

the SQL invalid, so findByProvider and provider linking failed outright rather than returning nothing. Neither had a suite pointed at a real Postgres until now.

Postgres: findById on a uuid primary key raises 22P02 invalid_text_representation for a string that is not a UUID, so an id taken straight off a request produced a 500 carrying the SQL rather than a clean unauthenticated error. Every other adapter returns null for an unknown id, and the store contract is "unknown id", not "crash". An unrepresentable id is treated as absent.

MySQL: json columns come back already parsed, so the Date fields nested in factors and actingAs arrived as ISO strings against a type that promises Date, and anything calling factor.completedAt.getTime() threw on MySQL alone. Rows are revived on read, the way the Postgres adapter and the Redis store already do.

  • 39aaa82: Roll back a plugin install that fails.

    use() claimed the plugin id first, then subscribed its event handlers, exposed its

facet and awaited install. A throw from any of those left the id claimed, the handlers subscribed and the facet reachable, so retrying under the same id hit "already installed" while the half-wired plugin kept receiving events.

Providers register first, before the id is committed, so a duplicate provider id throws where the author can fix the collision and reinstall under the same id. Everything after that is undone on a throw, and the id is committed last.

Providers has no unregister, so a provider registered by a plugin that fails later stays registered. That limit is named in the code rather than papered over.

  • Make the not-blank check constraints reject whitespace.

    length(trim(x)) > 0 only strips ordinary spaces, so a name, subject id, scope or

credential secret consisting of a tab, a newline or a form feed passed the check that exists to refuse exactly that. Seven constraints were affected: auth_credentials.secret, iam_assignments.subject_id and .scope, iam_policies.name, iam_roles.name and .scope, iam_subject_attrs.subject_id.

Each dialect gets the strongest form it has. Postgres and MySQL match a non-whitespace character (~ '[^[:space:]]' and REGEXP '[^[:space:]]'); SQLite has no regexp operator built in, so it trims the whitespace set explicitly and compares against the empty string. All three were verified against a real server for a tab, a newline, a carriage return, a vertical tab, a form feed and a plain space.

Existing databases need a migration: drop each constraint and add it back in the new form. Any row already holding a whitespace-only value has to be repaired first, or the ALTER TABLE is refused.

5.2.0

Minor Changes

  • Export the idempotency and events modules, and stop callers casting the drizzle bridge.

    • ./core/idempotency is a new subpath export. RedisIdempotency and IdempotencyImpl were reachable only through the bundle before, so an application wiring a production idempotency store had to cast into ConstructorParameters<typeof AuthEngine>[0]['idempotency'].
    • ./core/events is a new subpath export, and ./core now re-exports the Events type namespace and RedisEvents. Subscribing to the bus to persist an audit trail was not expressible without it: the engine defaults to an in-memory bus, so nothing reaches an auth_events table unless the application listens.
    • createDrizzlePgBridge, createDrizzleMysqlBridge and createDrizzleSqliteBridge are now generic over the profile, matching createSqlStores. They returned the base profile shape, so every caller with its own profile had to cast the whole bridge. The widening now happens once inside each adapter.
    • findByEmail is case-insensitive in all three drizzle adapters. The uniqueness constraint is unique (lower(profile->>'email')) but the lookup compared exactly, so an identity registered as Ada@example.com could never sign in again: signup stores the address verbatim and the lookup never found it.

5.1.0

Minor Changes

  • bc8a9ea: Prefix the drizzle store table exports with auth, add keyed JWT transport configuration with rotation support, and refuse the in-memory idempotency store outside development.

    Renamed drizzle table exports across mysql, pg and sqlite:

  • credentialsTable is now authCredentials

  • identitiesTable is now authIdentities

  • sessionsTable is now authSessions

  • eventsTable is now authEvents

The old names are gone, so update imports from @gentleduck/auth/adapters/drizzle/{mysql,pg,sqlite}. Only the exported bindings change — the physical table names are untouched, so no database migration is required.

Keyed JWT transport. jwtTransport config is now a JwtTransport namespace taking an explicit signKey plus the set of currently-valid verifyKeys, each with a kid and per-key alg (HS256 by default, ES256 and RS256 via a PEM private key). Keeping superseded keys in verifyKeys for an overlap window lets already-issued tokens keep verifying through a rotation instead of failing at cutover.

memoryIdempotency() now throws unless constructed with { development: true }, and always throws when NODE_ENV is production. Its state is per-process, so it stops deduplicating as soon as a second instance runs; it now fails loudly instead of degrading silently behind a load balancer.

The @gentleduck/iam peer range widens from an exact pin to ^, so a minor iam release no longer takes this package out of range.

5.0.0

Minor Changes

  • Restructure core into engine/ and config/ subfolders matching duck-iam patterns. Rename defineAuth → createAuth as primary entry point. Extract AuthEngineTypes and AuthDefine into dedicated types files. Add Auth prefix to all public classes.

Patch Changes

  • Updated dependencies
    • @gentleduck/iam@5.1.0

4.0.1

Patch Changes

  • fix: strip redundant iam/auth prefixes from public exports
  • Updated dependencies
    • @gentleduck/iam@5.0.1

4.0.0

Major Changes

  • Prefix all public exports with package namespace (Auth*/Iam*/IAM_*/AUTH/*) so the origin is clear at the type level when both packages are imported together. This is a breaking change — all consumers must update import references to the new names.

Patch Changes

  • Updated dependencies
    • @gentleduck/iam@5.0.0

All notable changes to @gentleduck/auth are documented here. Format follows Keep a Changelog; versioning follows Semantic Versioning.

Unreleased

Added (v0.2 plan items)

  • WebAuthn-MFA factor. Six new methods on MfaFacet: beginWebauthnMfaEnrollment, confirmWebauthnMfaEnrollment, beginWebauthnMfaVerify, verifyWebauthnMfa, hasWebauthnMfa, removeWebauthnMfa. Lazy-loads @simplewebauthn/server. Counter- rollback rejection emits suspicious events with signal webauthn-mfa-counter-rollback. New 'webauthn-mfa' Credential kind. (core/facets/mfa.ts, core/types/credential.ts)
  • KMS-envelope DataAtRest.IAdapter (KmsEnvelopeDataAtRest) driven by a vendor-agnostic Kms.IProvider contract. Reference AwsKmsProvider that lazy-loads @aws-sdk/client-kms. Encryption context binds wrapped DEKs to (identityId, field) so a leaked ciphertext can't unwrap DEKs from other rows. New subpath export @gentleduck/AUTH/core/dataAtRest. (DESIGN §1, §C5)
  • JwtTransport: EdDSA + live JWKS rotation. Adds 'EdDSA' (Ed25519) to the alg union with native node:crypto support. New rotateSignKey({ signKey, verifyKey? }) and retireVerifyKey(kid) methods promote a new signing key and retire the old one without recreating the transport instance; the old kid stays in the verify ring during the overlap window so already-issued tokens keep verifying. (DESIGN §3, §23 Q2)
  • Credential.IStore.patchMetadata(id, patch, ctx). Shallow merge into a credential's metadata and bump version in one step; either server-composed (SQL jsonb || $patch) or read-modify-write inside a transaction. Used by MfaFacet.confirmTotpEnrollment for an atomic confirm (replaces the prior delete+upsert race) and by passkey verify to advance the WebAuthn signCount on each assertion. Covered by the adapter compliance harness so every store implementation enforces the contract. (core/types/credential.ts, adapters/{memory,sql}/index.ts, adapters/__compliance__/index.ts)
  • AAL=3 detection on hardware-bound passkeys. MfaFacet.eligibleAal now returns 3 when the identity has a passkey credential whose enrollment metadata indicates deviceType === 'singleDevice' AND backedUp === false - matching NIST 800-63B hardware-binding. Cloud-synced passkeys (iCloud Keychain / Google Password Manager) stay at AAL=2. (core/facets/mfa.ts)
  • Vue / Svelte / Solid clients. Three new framework adapters wrapping the vanilla AuthClient, each as an optional peerDep:
    • @gentleduck/AUTH/client/vue - createAuthVuePlugin + useSession/useSignIn/useSignOut composables. Vue is resolved at runtime via require('vue') so the auth core has no Vue in its module graph.
    • @gentleduck/AUTH/client/svelte - createAuthStore returning a duck-typed Svelte-store contract; no Svelte import needed at typecheck time.
    • @gentleduck/AUTH/client/solid - AuthProvider + Solid signals; uses real solid-js types.

Security

Five rounds of focused security review against the auth-critical surface. 42 fixes shipped; each is covered by a regression test (600 -> 683 vitest tests over the five rounds).

Round 5 (AuthRoot wiring / sessions matrix / anomaly detectors):

  • P0: AuthRoot.resolveSession(req, { expectedTenantId }) now refuses a session whose tenantId differs from the request's expected tenant. Without this, a SID/JWT minted under tenant A presented at a tenant-B endpoint returned tenant-A's identity. (core/auth.ts)
  • P0: FlowsFacet.completeSignUp now routes through SessionsFacet.rotateOrCreate({ purpose: 'guest-promotion', previousSid }) so the prior guest SID is revoked atomically with the new user SID. DESIGN §37 mandated this; the prior create() call bypassed the fixation guarantee. New optional previousSid arg. (core/facets/flows.ts)
  • P0: End-to-end CSRF auto-enforcement on every server adapter (express / hono / next). SessionsFacet.create returns a plaintext csrfToken; CookieTransport.issue emits the __Host-duck-csrf cookie when supplied; mounted signin / signout / provider-begin routes call csrfGuard(auth, req) first. Double-submit (Layer-2) is correctly skipped when no session exists yet (signin / signup-begin), so Layer-1 (Sec-Fetch-Site) alone protects the unauthenticated state-changes. (core/csrf.ts, core/transport/cookie.ts, core/facets/sessions.ts, server/{express,hono,next}/index.ts)
  • P1: AuthRoot.strict({ env: 'production' }) now catches an explicitly-passed NoopLimiter (via __isNoopLimiter brand) and walks ALL three stores (identities + sessions + credentials) for the memory-adapter heuristic. Closes "mixed adapter" misconfigs where memory sessions slipped past the production check. (core/auth.ts)
  • P1: impossibleTravelDetector no longer no-ops when lat or lon is 0 (Equator / Prime Meridian) and rejects negative elapsed-ms (clock-skew or attacker-planted lastSeen) before the speed calc. Pathological maxKmPerHour (zero, negative, non-finite) throws at construction. (core/anomaly/impossible-travel.ts)
  • P1: deviceFingerprintDetector IPv6 subnet now expands compressed addresses (::1, 2001:db8::1) before taking the /48 prefix. Previous naive split(':').slice(0,3) collapsed unrelated networks to the same subnet key, suppressing new-device MFA prompts. (core/anomaly/device-fingerprint.ts)

Round 4 (MFA / dataAtRest / identities / cookie):

  • P0: BackupCodesFacet.generate no longer uses Math.random() to top off codes or modulo-bias the base64url input over the readable alphabet. New generateBackupCode draws every character from crypto.randomBytes with rejection sampling against the upper modulo-bias tail. Restores the nominal 32^8 ~ 40 bits entropy (previous implementation was effectively ~30 bits). (core/mfa/backup-codes.ts)
  • P1: BackupCodesFacet.verify uses timingSafeEqual on the per-code hash and drives the credentials loop to completion so early-exit timing cannot leak match position. Successful consumption now soft-revokes (revoke()) rather than hard-deletes the row, so a subsequent replay attempt surfaces against an already-revoked audit trail instead of a phantom code. (core/mfa/backup-codes.ts)
  • P1: RememberMeFacet.revoke(identityId, credentialId) now requires identity ownership and refuses to delete rows that belong to a different identity. Without this, a consumer surfacing revoke to an HTTP route that takes ?credentialId= would let any authenticated user revoke any other user's trusted-device row. (core/mfa/remember-me.ts)
  • P1: IdentitiesFacet.getByEmail (and bulkCreate lookup) now email.trim().toLowerCase() the input. Without normalization, Bob@x.com and bob@x.com created duplicate identities, password resets went to only one, and case variants leaked via differential responses. (core/facets/identities.ts)
  • P2: AesGcmDataAtRest accepts previousKeys so ciphertexts written under a rotated kid can still be decrypted. Previously the kid was parsed from the ciphertext but discarded - every key rotation stranded existing data with silent auth-tag mismatch. Duplicate kids across (current, previousKeys) throw at construction. (core/dataAtRest/aes-gcm.ts)
  • P2: CookieTransport constructor enforces all three __Host- prefix invariants: no Domain, Path=/, and Secure=true. Browsers silently drop cookies that violate them - fail-fast catches the misconfig in CI rather than at runtime as "session never sticks". (core/transport/cookie.ts)
  • Carry-over P2 (Round 3): Magic-link _send no longer forwards the channel adapter's error.message to the caller (would reflect token plaintext on SMTP/SES errors). (providers/magic-link/index.ts)
  • Carry-over P2 (Round 3): Webhook signatures bind the timestamp (X-Duck-Timestamp header), constructor SSRF-guards endpoint URLs against loopback / private / link-local / cloud-metadata hosts. (core/webhooks/index.ts)

Three prior rounds (JWT, DPoP, FlowsFacet, OAuth refresh, IdempotencyFacet, M2M, Hijack, OIDC, SQL examples, passkey WebAuthn, CSRF, compliance presets) detailed below.

Round 3 (passkey / magic-link / CSRF / webhooks / compliance):

  • P1: Passkey counter-rollback detection - previously dead code (newCounter was read then void-discarded). Now rejects on newCounter !== 0 && newCounter <= stored, emits a passkey-counter-rollback suspicious event, and advances the stored baseline via Credential.IStore.patchMetadata so the baseline tracks the authenticator across assertions (closes the earlier "needs metadata-update method" gap). (providers/passkey/index.ts)
  • P1: Passkey complete now binds the credential to (a) the caller-supplied email hint by asserting cred.identityId === findIdentityByEmail(email).id, and (b) the WebAuthn response.userHandle bytes. Previously the email was a UI hint only and the userHandle was ignored - letting an attacker who possessed any valid passkey and knew a victim's credential ID (not a secret) sign in as the victim. (providers/passkey/index.ts)
  • P1: Passkey registration now hashes the identityId to a stable 32-byte WebAuthn user.id via sha-256 instead of TextEncoder. Browsers silently truncate user.id at 64 bytes; long composite IDs (ULIDs, tenant:user namespaces) collided on the authenticator and surfaced the wrong identity on discoverable-credential sign-in. (providers/passkey/index.ts)
  • P2: Webhook signatures now bind the timestamp: signWebhookBody(secret, body, timestamp) HMACs ${ts}.${body} and the deliverer emits X-Duck-Timestamp. verifyWebhookSignature takes { timestamp, toleranceMs } and rejects replays outside the window (default 5 min). (core/webhooks/index.ts)
  • P2: Webhook constructor now SSRF-guards endpoint URLs. Refuses non-HTTPS by default (escape hatch allowInsecure: true), refuses loopback / private / link-local / cloud-metadata hosts even with allowInsecure. Closes the metadata-server exfil vector for any deployment that populates the endpoint list from tenant input. (core/webhooks/index.ts)
  • P2: Magic-link _send no longer forwards the channel adapter's error.message to the caller. SMTP / SES error objects commonly embed the rendered message body - which for magic-link is the plaintext token URL - into their error metadata. Previously surfacing it via AUTH/PROVIDER_FAILED.detail broke the single-use invariant by reflecting the token in the HTTP response + any error sink. (providers/magic-link/index.ts)
  • P2: CSRF origin-only mode now refuses Sec-Fetch-Site: none when no Origin allowlist is configured. Direct user-navigation POSTs
    • spoofable non-HTTP contexts both surface as none - gating on Origin was the only Layer-1 defense and the prior code fell open when the allowlist was empty. (core/csrf.ts)
  • P2: applyCompliancePreset brands the returned config with a non-enumerable __compliancePreset marker so AuthRoot.strict() can detect the preset and auto-call assertComplianceStrict. Previously, applyCompliancePreset ratcheted only the numeric knobs (password length, session TTLs) - minAal, requireDataAtRest, requiredStrictChecks silently dropped unless the operator remembered to call assertComplianceStrict by hand. New readCompliancePreset helper exposes the marker. (core/compliance.ts)
  • P2: ApiKeysFacet.verify runs a synthetic sha256 + store lookup on the prefix-mismatch path so timing signature matches the success path. Closes a timing oracle that distinguished valid-format junk from "live api-key wrong secret". (core/facets/apikeys.ts)

Round 2 (M2M / Hijack / OIDC / adapter consistency):

Round 2 (M2M / Hijack / OIDC / adapter consistency):

  • P0: M2MFacet.exchange now signs the granted scopes into the JWT via a new Transport.IssueOpts.scope + JwtPayload.scope. The scopeMode: 'intersect' / 'strict' mechanism is now enforced on the wire - resource servers branch on payload.scope. Previously the granted set lived only in the envelope and was dropped on serialization. (core/facets/m2m.ts, core/transport/jwt.ts, core/types/transport.ts)
  • P1: HijackFacet.evaluate evaluates BOTH IP and UA branches and picks the strongest reaction (precedence: revoke > mfa > rotate > ignore). Prior implementation returned on the first non-ignore branch, silently downgrading the UA reaction. Also: one-sided fingerprint absence is now treated as drift (downgraded to rotate) so a request stripping the User-Agent header doesn't bypass detection. (core/facets/hijack.ts)
  • P1: M2MFacet now refuses to mint a token when the caller-supplied tenantId disagrees with the credential's own tenant; falls back to the credential's tenantId when the caller omits it. ApiKeysFacet.verify returns the row's tenantId. (core/facets/apikeys.ts, core/facets/m2m.ts)
  • P1: ApiKeysFacet.verify runs a synthetic sha256 + store lookup on the prefix-mismatch path so the timing signature matches the success path. Closes a timing oracle that distinguished "live api-key format" from "junk". (core/facets/apikeys.ts)
  • P2: buildOidcDiscovery parses + canonicalizes the issuer via new URL(), refuses non-HTTPS in production (escape hatch allowHttp: true), spreads extraClaims BEFORE canonical fields so the extension hatch cannot shadow issuer/jwks_uri/etc., and drops 'none' from token_endpoint_auth_methods_supported by default. (oidc/index.ts)
  • P2: Drizzle adapter examples - pg/mysql/sqlite findByEmail and findByProviderSub aligned with findById's null-tenant semantics (null-tenant rows are "global identities" reachable from any tenant). Previously inconsistent - surfaceable as duplicate identity creation on next OAuth callback. (adapters/sql/examples/drizzle-{pg,mysql,sqlite}.example.ts)
  • Docs: M2MFacet class JSDoc now explicitly notes that revocation is lazy (JWT lives until exp regardless of api-key revocation) so operators don't assume "revoke == kill". (core/facets/m2m.ts)

Round 1 (JWT / DPoP / FlowsFacet / OAuth refresh / IdempotencyFacet):

  • P0: completePasswordReset now requires metadata.kind === 'password-reset'. The kind: 'recovery' credential row is shared by password-reset, email-verification, account-deletion, and signup-flow tokens. Without this assertion, an attacker holding any recovery-kind token (e.g. an email-verification link) could call completePasswordReset and set the victim's password. (core/facets/flows.ts)
  • P1: resolveBySid + JwtTransport.verify now enforce actingAs.expiresAt. The 1-hour cap on impersonation was previously informational only - sessions stayed live for the full session TTL (default 7d). (core/facets/sessions.ts, core/transport/jwt.ts)
  • P1: JwtTransport.verify now reconstructs session.fresh from a new frsh claim (rotatedAt epoch) instead of hard-coding true. The freshness gate that protects completePasswordReset / checkStepUp in cookie mode now applies identically in JWT mode. New JwtTransport.IConfig.freshnessMs knob (default 5min) matches the cookie-session default. (core/transport/jwt.ts)
  • P1: OAuth refresh-token rotation closes the TOCTOU race between findByHashedSecret and revoke + upsert. The row is claimed via Credential.rotate(id, sameSecret, expectedVersion) (CAS on version) before the IdP exchange runs; concurrent refreshes get AUTH/STALE_WRITE on the second rotate and revoke the family per RFC 6749 §10.4. (providers/oAUTH/core/refresh.ts)
  • P1: IdempotencyFacet.handle no longer double-executes when a worker loses the claim() race. Both the in-memory and Redis stores filter the claim tombstone, and the facet polls (bounded exponential backoff, default 5s) until the originator's put() lands - returning a 409 if the originator crashed without writing. (core/facets/idempotency.ts)
  • P2: AnomalyFacet.decide() now fails CLOSED on non-finite signal scores. A buggy detector emitting NaN previously sank into 'allow' via comparison short-circuits. (core/facets/anomaly.ts)
  • P2: IdempotencyFacet.handle now takes an optional identityId and scopes the cache key by it. A cached response minted for Alice can no longer be served to Bob under the same tenant + key. For anonymous routes the scope falls back to _anon (callers should fingerprint by IP+UA upstream). (core/facets/idempotency.ts)
  • P2: JwtTransport constructor rejects duplicate-kid entries in verifyKeys (silent last-wins) and rejects signKey whose alg/key mismatches a verifyKey under the same kid. HS256 enforces key equality; asymmetric algs only enforce alg parity (verifyKey holds the public counterpart of signKey's private material). (core/transport/jwt.ts)
  • P2: DPoPVerifier.verify now rejects authed-request proofs whose ath claim is missing (RFC 9449 §4.3) AND rejects proofs carrying ath when no access token is supplied to verify against. (core/transport/dpop.ts)
  • P2: DPoPVerifier accepts an optional expectedNonce (string or thunk) per RFC 9449 §8/9. Lets multi-pod deployments tighten the replay window beyond the local jti store via a rotating server-issued nonce. (core/transport/dpop.ts)
  • P2: OAuth refresh now emits a suspicious event with signal oauth-refresh-unknown-row when findByHashedSecret returns null. Replay of a hard-deleted (post-GC) row token surfaces as a leak signal operators can route to oncall, even though the familyId is no longer available to auto-revoke. (providers/oAUTH/core/refresh.ts)

Added

  • Namespace migration: every public exported type lives inside its owner namespace (SessionsFacet.IConfig, Captcha.IVerifier, Identity.IIdentity). scripts/audit-namespaces.ts enforces the convention. scripts/inline-types-into-namespace.ts automates the migration.
  • CLI: migrate <pg|mysql|sqlite> emits CREATE TABLE DDL matching the SqlBridge row contract. Tenant + composite indexes included.
  • CLI: keys rotate hs256 mints a new HS256 secret + rollover snippet that keeps the previous kid on verifyKeys for in-flight JWTs. Default --new-kid includes a random suffix to prevent collisions on sub-second rotation.
  • CLI: emit-openapi dynamic-imports the local auth.ts and prints the OpenAPI 3.1 spec (or writes via --out=<path>).
  • @gentleduck/AUTH/test: createTestAuth() helper that builds an AuthRoot wired to in-memory adapters with a TestAuth.IOverrides shape for surgical swaps in test code.
  • AnomalyFacet.decide() + IDecision type: 'allow' | 'step-up' | 'deny' recommendation per signal set. IConfig.reactions map for per-kind overrides. unregister(id) complements register().
  • AnomalyFacet: NaN / non-finite scores from a buggy detector fail CLOSED ('deny'), not silently allow.
  • adapters/sql/examples/: parallel Drizzle reference implementations for MySQL and SQLite alongside the existing pg example.
  • SECURITY.md: dedicated security policy + Deployment Hardening Guide (15 items covering transport, JWT rotation, DPoP, rate limiting, idempotency, password hashing, magic-link channels, OAuth reuse detection, CSRF, MFA step-up freshness, impersonation, anomaly detectors, PII redaction).

Changed

  • AnomalyFacet constructor accepts Partial<IConfig> and merges with DEFAULT_ANOMALY_CONFIG so consumers can supply just the field they want to tweak.
  • cli migrate mysql emits VARCHAR(64) for primary-key + index columns (MySQL refuses TEXT PRIMARY KEY with ERROR 1170). Same dialect emits CREATE INDEX without the IF NOT EXISTS guard (unsupported pre-8.0.29).
  • cli migrate and cli emit-openapi --out=<path> flag is path-contained to the current working directory (refuses ../../... traversal).
  • Drizzle reference examples (pg / mysql / sqlite): merge(), insertProviderLink(), deleteProviderLink() are now tenant-scoped. Schemas now declare the (kind, secret) composite index plus expires_at / absolute_expires_at session indexes that the bridge contract documents but had been omitted.
  • Drizzle mysql + sqlite examples: listByIdentity uses !== undefined for the tenant filter so empty-string tenant ids don't silently drop the predicate. sqlite findByProviderSub matches links whose stored providerSub is null when the lookup sub is null.

Documentation

  • STATUS.md refreshed: 617 -> 626 -> 632 tests passing across 68 files; v0.1 coverage updated from ~80% to ~98% of v1.0 MUST surface; tooling list updated to mention the audit + inline scripts.
  • DESIGN.md [pending v0.2] markers removed from sections that have shipped (passkey, observability, OpenAPI, i18n, CLI, test helpers). §22 v1.0.0 scope rewritten to reflect actual shipped vs. follow-on. §23 open-questions split into "closed" (WebAuthn library, DPoP, attestation policy) vs. "still open" (tenant resolution, jose migration, GeoIP source, OIDC cache, federation conflict UX, native SDKs).
  • README.md surface tables synced: 6 OAuth providers, passkey discoverable+username, SAML, SQL bridge + Drizzle reference impls, 6 channels, 9 server adapters, OIDC + OTel + i18n + test helper subpath exports listed. Status table reflects v0.1 reality.

Removed

  • scripts/add-namespaces.ts and scripts/add-namespaces-v2.ts (superseded by audit-namespaces.ts + inline-types-into-namespace.ts).

0.1.0 - prior to this changelog

Initial pre-publish surface. Faceted AuthRoot with 14 facets, 4 transports, memory + redis + sql-bridge adapters, 11 providers, 9 server adapters, 2 clients, 6 channels, CLI, OpenAPI generator, OIDC discovery, OpenTelemetry instrumentation, i18n catalog, adapter compliance harness, bundle benchmark, threat model. See STATUS.md for the full shipped surface.