Skip to main content
Urbicon UI

// Auth

Auth Reference|

The @urbicon-ui/auth reference. This page renders the docs/AUTH.md that ships inside the package, the same file your editor opens from node_modules/@urbicon-ui/auth/docs/AUTH.md.

Zero-dependency authentication, user management, and notification system for SvelteKit. Part of the Urbicon UI monorepo.

Architecture

@urbicon-ui/auth
├── Server (import from '@urbicon-ui/auth/server')
│   ├── Core: JWT (HS256 default, ES256 opt-in + JWKS), PBKDF2 password hashing, CSRF, rate-limiting, security headers
│   ├── Handlers: login, logout, register, forgot-password, reset-password, verify-email, me
│   ├── Handle-Hook: createAuthHandle() — session hydration, route guard, CSRF, headers
│   ├── Passkeys: WebAuthn registration + authentication (CBOR, ECDSA P-256, RSA verification)
│   ├── Federation (SSO): createJWKSHandler (IdP side) + createFederatedAuthHandle (consumer side)
│   ├── Notifications: SSE manager, push service (RFC 8291/8292), event registry, dispatch
│   ├── Email: Transport interface + Lettermint adapter + console logger
│   └── Adapters: Repository interfaces + Prisma + in-memory adapters + conformance suite
│
├── Client (import from '@urbicon-ui/auth')
│   ├── Stores: createAuthStore(), createNotificationStore() — Svelte 5 Runes
│   ├── 14 UI Components: blocks-based, unstyled/slotClasses, snippet overrides
│   │   └── fetching components accept `fetcher` (DI for fetch — mocks in demos/tests,
│   │       custom retry/auth layers; default: global fetch)
│   └── Utils: service worker registration, push subscription
│
├── i18n (import from '@urbicon-ui/auth/i18n/en' or '/de')
│   └── EN + DE locale bundles (extensible)
│
└── SW (import from '@urbicon-ui/auth/sw')
    └── handlePushEvent, handleNotificationClick

Zero-Dependency Design

All crypto is implemented via Web Crypto API (crypto.subtle):

Feature Implementation Standards
Password hashing PBKDF2 (600k iterations, SHA-256)
JWT sessions HMAC-SHA256 (HS256, default; timing-safe comparison) or ECDSA P-256 (ES256, createJWKSHandler serves the JWKS) RFC 7515, 7517, 7638
Web Push ECDH + HKDF + AES-128-GCM RFC 8291, 8292, 8188
Passkeys CBOR decoder, ECDSA/RSA verification WebAuthn Level 2, FIDO2
Token hashing SHA-256
bcrypt migration Dual-verify (detects $2b$ prefix, re-hashes to PBKDF2)

Runtime requirements

Zero dependencies, but the server runtime must provide a couple of platform globals:

  • Web CryptoglobalThis.crypto with crypto.subtle + getRandomValues backs every crypto path above. Global since Node 20, always present in Bun, Deno and edge runtimes.
  • Node Buffer — password hashing (PBKDF2 hex encoding) and the TOTP secret cipher (base64) use Buffer. Password hashing is on the login/register path, so in practice this makes the package Node ≥ 20 or Bun (both ship Buffer globally); the Prisma adapter is Node/Bun-only regardless.

Bottom line: target Node.js ≥ 20 or Bun. Edge/Workers/Deno-deploy need a Node-compatibility layer that polyfills Buffer (e.g. Cloudflare's nodejs_compat); the Web Crypto paths themselves are edge-clean.

Package Exports

Export Condition Content
@urbicon-ui/auth Client + types Stores, components, types
@urbicon-ui/auth/server Server only Handlers, auth core, adapters
@urbicon-ui/auth/server/adapters/prisma Server only Prisma adapter factory (createPrismaRepos)
@urbicon-ui/auth/server/adapters/in-memory Server only In-memory adapter (createInMemoryRepos, per-repository factories on a createInMemoryStore()) — dev/test
@urbicon-ui/auth/server/adapters/conformance-core Server (tests) The suite without a runner import — pass your own describe/it/expect (bun:test as-is; jest needs expect: (a) => expect(a))
@urbicon-ui/auth/server/adapters/conformance Server only (tests) Adapter conformance suite (describeRepositoryConformance)
@urbicon-ui/auth/server/email/lettermint Server only Lettermint email transport
@urbicon-ui/auth/server/email/console Server only Console email transport (dev)
@urbicon-ui/auth/sw Service Worker Push + notification click handlers
@urbicon-ui/auth/i18n/en Universal English locale
@urbicon-ui/auth/i18n/de Universal German locale

UI Components

All components use @urbicon-ui/blocks primitives and support:

  • t — locale overrides as PartialAuthLocale: any subset, deep-merged over the active built-in bundle by mergeAuthLocale (both root-exported). Overriding one string never blanks the rest; a hand-rolled bundle missing newer keys resolves them from the base. AuthLocale itself is fully required — no per-key fallback literals in markup.
  • unstyled — strips every default class, including inner wrappers and list-item internals; structural state that only styling used to carry is exposed as data attributes instead (data-met on RegisterPage requirement rows, data-unread on NotificationCenter items). Functionality never disappears with the flag. Set it on the prop or on <BlocksProvider unstyled> — a provider-wide flag reaches these components too. Changed: it used to strip only the blocks primitives inside them, leaving the auth components' own classes in place; a project relying on that half-stripped state now gets the whole thing.
  • slotClasses — per-slot class overrides (e.g. root, card, form, field)
  • preset — a named look registered on <BlocksProvider presets={{ LoginPage: { … } }}>, resolved after the provider's defaults and before this instance's slotClasses. Reach for it when the same override would otherwise be repeated at every usage site. NotificationListener is the one component without any of the three — it renders no markup.
  • Snippet overridesheader/footer/links on all five pages, item (NotificationCenter), qr (TwoFactorManager)
  • Semantic design tokenstext-text-primary, bg-surface-quiet, etc. The tokens come from the consumer's blocks stylesheet; this package's own @urbicon-ui/auth/style/index.css adds only the Tailwind @source for its components.
  • The five pages share one internal skeleton (_shared/AuthPageShell.svelte: wrapper → Card → h1 → aria-live error region); the region itself is _shared/FormErrorAlert.svelte, the one place a request outcome (error or success) becomes markup — every component reports into it, AccountSettings once per form. Neither is a public export.
Component Purpose
LoginPage Login form with optional passkey button
RegisterPage Registration form (invitation-gated)
ForgotPasswordPage Password reset request
ResetPasswordPage Password reset with confirmation
VerifyEmailPage Auto-verifying email confirmation
InvitationManager Admin invitation management
PasskeyManager WebAuthn credential management
AccountSettings Change name/email/password + delete account
SessionManager List active sessions + sign out devices
TwoFactorManager Enrol/disable TOTP 2FA + show backup codes
NotificationCenter Notification list with read/delete
NotificationBadge Unread count badge
NotificationListener Headless SSE listener
PushPermissionPrompt Push notification opt-in

Client stores

createAuthStore / createNotificationStore are the headless counterparts for consumers building their own UI. They ride the same infrastructure as the components: a fetcher config option (mock backends, retry layers), the tolerant postJson/getJson request core, and the wire contract instead of hardcoded English — a failed auth action returns { success: false, error?, code? } (server prose + machine code; localize via errorMessageFromCode(code, t, error)), a request that never reached the server synthesizes code: 'network_error'. The notification store records the same shape on lastError (cleared by the next success), returns false from failed operations instead of silently no-opping, and a failed load keeps the existing list rather than blanking it into a fake empty inbox. logout clears the local state unconditionally but still reports whether the server revoked the session (threading the failure body's wire contract). checkStatus distinguishes "signed out" from "could not ask": a 200 or the me-contract's 401 { user: null } resolves the user and reports success, while a transport failure or non-contract error leaves the current user untouched and reports the failure — a route guard can retry instead of bouncing a signed-in user over a transient blip.

Password policy

config.password is the only definition of what a password must satisfy. The server measures against it (validatePasswordStrength), and createPasswordPolicyHandler(deps) publishes it so <RegisterPage>, <ResetPasswordPage> and <AccountSettings> gate against the same rules instead of a hand-kept copy in component props:

// src/routes/api/auth/password-policy/+server.ts
export const GET = createPasswordPolicyHandler(authDeps).GET;

The three components read it from policyPath (default /api/auth/password-policy) on mount. Without the route they fall back to DEFAULT_PASSWORD_POLICY — min 8, no character classes, i.e. what an unconfigured server enforces — and warn in dev. When your route already loads the policy, pass it in as passwordPolicy (resolvePasswordPolicy(config.password) in a +page.server.ts) and no request is made.

A refusal is localized even when the client gated on the wrong policy. The three handlers that take a new password answer a rule failure with the English prose and the refusal in machine form:

{ "error": "Password must be at least 12 characters", "code": "validation_error",
  "errors": ["Password must be at least 12 characters"],
  "rules": ["minLength"],
  "passwordPolicy": { "minLength": 12, "requireUppercase": false, … } }

errorMessageFromCode prefers the server prose for validation_error (it names the field), so without rules a German user reads the English sentence whenever the form measured against a different policy — the exact string this work exists to remove. The shipped forms read rules first, render their own labels for them, and adopt passwordPolicy, so the next attempt is gated on the rules the checklist now shows instead of failing the same way again. A consumer without i18n sees error/errors unchanged.

The endpoint's response is a five-field projection (minLength, requireUppercase, requireLowercase, requireDigit, requireSpecial) and nothing else — in particular never pbkdf2Iterations. It is unauthenticated on purpose: registration and password reset are signed-out flows, and one failed submit already spells the policy out ("Password must be at least 12 characters").

The checklist renders from the first paint rather than from the first keystroke, and the password field points at it with aria-describedby: a description added to an already-focused field is not reliably re-announced, and there is deliberately no live region (it would fire on every keystroke). Set showRequirements={false} to drop both — a refused password still names the rules it missed, so the reason stays reachable.

Breaking in 8.17.0

Passkeys can be renamed. createPasskeyHandlers gains item.PATCH on …/[credentialId] and <PasskeyManager> grows an inline rename control on each row, so PasskeyRepository.rename — required of every adapter since it was declared, and reached by no shipped code path — now has one.

Mount the new verb beside the delete it shares a route with:

// …/[credentialId]/+server.ts
export const DELETE = passkey.item.DELETE;
export const PATCH = passkey.item.PATCH;

The endpoint takes { name }, bounded by the same display-name rule as the profile name (non-empty once trimmed, at most 256 characters, stored trimmed), and answers 200 { passkey } with the row in the shape list returns. A name that is absent, blank or over the bound is validation_error (400); a credentialId that is not the caller's — absent or someone else's, deliberately one answer — is the new passkey_not_found (404). Ownership is decided in the handler, so the refusal does not rest on the adapter's own scoping.

Registration now holds a supplied name to the same rule. registrationVerify passed name straight to the adapter unchecked, so a label registration accepted could be one the rename refuses — 100 KB of it, blank, or not a string — and it came back in every list response afterwards. A request that supplies such a name now answers validation_error (400) where it answered 201. Omitting name is unchanged and remains what <PasskeyManager> sends: the adapter's 'Passkey' default still applies.

Adapters need no edit. The other break is the locale bundle: AuthLocale is fully required, so a hand-written one stops compiling until it carries the new keys — the intended signal, not a regression. Added: auth.errors.passkeyNotFound and passkeys.rename, passkeys.renameLabel, passkeys.renameSave, passkeys.renameCancel, passkeys.renamed. Consumers passing a PartialAuthLocale override are unaffected.

Breaking in 8.16.0

The HTTP status is now a property of the error code. One code answers under exactly one status, everywhere it is sent, so code and status can no longer disagree between two handlers. Where one name had been carrying two statuses there are now two names, and both splits use the same rule: 401 means the request was an attempt to authenticate and the credential it carried was missing or refused; everything else about a request answers 400, including a wrong secret on a request that authenticates nobody.

  • Two-factor. invalid_code keeps the sign-in step (401, unchanged). Enrolment answers the new two_factor_setup_code_invalid (400, unchanged) when the code typed against the staged secret does not match.
  • Passkeys. passkey_verification_failed keeps the sign-in ceremony, and its status changes from 400 to 401 — the one wire change in this release. A sign-in ceremony that refuses the credential now answers the same status class as a refused password or second factor. Enrolment answers the new passkey_registration_verification_failed (400). passkey_credential_deleted also moves from 400 to 401: the browser did present a credential and the server refused it, and "unknown" rather than "invalid" is the reason — which is what the code carries. Every way a passkey sign-in can be refused now answers 401.

A client that switches on the code falls through to its default arm for the two enrolment cases until it adds the new names; one that handles only the sign-in codes is unaffected by the renames. A client that branches on the status of a failed passkey sign-in must expect 401 where it saw 400.

AuthLocale is fully required, so a hand-written locale bundle stops compiling until it carries the new keys — the intended signal, not a regression. Added: auth.errors.twoFactorSetupCodeInvalid and auth.errors.passkeyRegistrationVerificationFailed. Consumers passing a PartialAuthLocale override are unaffected.

Breaking in 8.7.0

<RegisterPage>'s passwordMinLength / requireUppercase / requireLowercase / requireDigit / requireSpecial props are gone — they were the second copy. Their old defaults (min 8 plus upper, lower and digit) were also stricter than an unconfigured server, so the checklist blocked passwords the server would have taken. Configure config.password and mount the endpoint; the UI follows.

requireSpecial is new on the server side. The registration checklist has offered that rule since v8 while validatePasswordStrength ignored it, so a UI demanding a symbol refused nothing the server would have accepted; it is now enforced, and off by default like the other character classes.

AuthLocale is fully required, so a hand-written locale bundle stops compiling until it carries the new keys — that is the intended signal, not a regression. Added: auth.errors.csrfFailed, auth.errors.passkeyVerificationFailed, auth.errors.connectionLimit, and the auth.passwordRequirements subtree (label, met, notMet, failed, rules.*). Moved: auth.register.requirementsLabel and auth.register.requirements.* became auth.passwordRequirements.label and auth.passwordRequirements.rules.* — they are read by the account panel and the reset page too, so register was the wrong home for them. Consumers passing a PartialAuthLocale override are unaffected unless they overrode those two.

Consumer Integration — staged setup

The full copy-paste walkthrough lives in packages/auth/README.md → Getting Started, structured as three stages that build on each other. This section is the architectural view: which building blocks each stage swaps in, and the invariants that hold across all of them.

Cross-cutting: createAuthHandle is mandatory in every stage — it hydrates the session (locals.user), guards routes, applies the response security headers, and enforces CSRF. The handler factories alone do none of that.

Stylesheet: every stage that mounts a component needs @import '@urbicon-ui/auth/style/index.css'; after the blocks import in the app's Tailwind stylesheet (see README → Installation). The file carries no tokens — only the @source directive that lets the consumer's Tailwind build reach the classes inside this package. Without it the sm: layouts and the link colour of the auth pages are missing from the compiled CSS.

createAuthHandle applies response security headers automatically: X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy (always on) plus Strict-Transport-Security (only in a secure deployment) and a Content-Security-Policy baseline (frame-ancestors 'none'). Tune or disable the latter two via config.securityHeaders — e.g. { csp: "default-src 'self'", hsts: false }. CSP is a hook: a full policy is app-specific, so supply your own once you know which origins the app loads.

Responses are uncacheable by default. Everything the handlers answer is scoped to one account — the me payload, freshly minted tokens, the session, invitation, notification and passkey lists, 2FA material, WebAuthn challenges, and the per-account outcome a bare { success: true } reports — so every response the package builds carries Cache-Control: no-store. It is a property of the endpoint rather than of each call site: refusals take it from authError, every other response from the wrapper around each handler bundle, so a route cannot answer without it and no handler has to remember to add it. That matters because SvelteKit's json() emits nothing but content-type and content-length, and a 200 carrying no cache directive is heuristically storable (RFC 9111 §4.2.2): a shared cache in front of the app, keyed by URL and seeing no Vary: Cookie, would be free to serve one account's notification rows, notification preferences or passkey inventory to the next caller. The notification SSE stream is no-store for the same reason — no-cache, the conventional stream header, only forces revalidation and still lets a shared cache keep the response.

Three endpoints are public and say so, each with Cache-Control: public, max-age=300: createJWKSHandler (the published public key set), createPasswordPolicyHandler (the policy the sign-up and reset forms gate against) and createPushKeyHandler (the VAPID public key browsers need in order to subscribe to Web Push). None of the three is scoped to a caller, and all three accept the same bounded staleness — after a key rotation or a tightened policy a warm client keeps using the previous value for up to five minutes, which costs one refused push subscription or one rejected submit and corrects itself on the next fetch.

Those three are the whole exception list among the responses this package builds. The rule does not reach what SvelteKit generates from a throw: the handle's 302 to the login page for a guarded route, or the 500 raised when a consumer hook throws. Neither carries a directive. Neither status is in the heuristically-cacheable set either, so nothing that motivated the rule reopens there — but if you put a cache in front of the app, those two are the responses this package has not spoken for.

Secure deployment

Whether the deployment runs over HTTPS is answered once, for every cookie the package writes: it is secure unless any of the three cookie configs a consumer can declare it on — jwt, csrf, refreshToken — says cookieSecure: false. A deployment is one transport, so a single false settles the question for all of them. Five decisions hang off that one answer: the __Host- prefix and the Secure attribute of the 2FA and passkey-ceremony cookies, the csrf.useHostPrefix warning, HSTS, and whether the wiring-time hardening warnings (rateLimit: null, lockout: null, a weak pbkdf2Iterations) treat the config as production. The signal is deliberately this wide: a __Host- + Secure cookie is dropped by the browser over plain HTTP, and the flows that lose it report a challenge-store failure (no_2fa_challenge, a missing passkey ceremony handle) — the cookie's name is nowhere in that message, so a narrower signal would cost the operator the whole debugging trail.

Each cookie is still written with its own cookieSecure ?? true, so the three flags can disagree, and both directions bite: csrf.cookieSecure: false alone switches HSTS and the production warnings off while the session cookie stays Secure, so nobody can log in over HTTP; jwt.cookieSecure: false with csrf.doubleSubmit and no csrf.cookieSecure writes a Secure CSRF cookie over plain HTTP, the browser drops it, and every mutating request answers 403. createAuthDeps therefore warns at wiring time whenever the configured slices disagree ([auth] cookieSecure disagrees between cookie configs: …). Only configured slices count — an absent csrf / refreshToken writes no cookie — and csrf.useHostPrefix: true counts as Secure, because the prefix forces it. Set cookieSecure: false on all of them for an HTTP dev deployment, or on none of them for HTTPS. One pair is not a warning but a wiring-time error: cookieSameSite: 'none' on a cookie that is not Secure — browsers reject it outright, so the config is refused.

Stage 1 — Quickstart (dev)

In-memory adapter (createInMemoryRepos) + console email transport (createConsoleEmailTransport) + jwt.cookieSecure: false. No database, no mail server, wiped on restart — dev only. createAuthDeps still applies the secure brute-force defaults (login rate-limit + lockout), so the dev flow isn't unprotected. Every key of rateLimit carries a default. Configuring some keys is a merge, never a replacement (rateLimit: { register } still leaves login protected), and the default table is derived from the key set — a new endpoint key cannot ship without one. There are two opt-outs: rateLimit: null disables limiting for every handler, and a single key set to null disables just that one — rateLimit: { register: null } is "deliberately unlimited registration" without giving up the login brake. An omitted key is not an opt-out; it gets the default. The lockout default applies only when you configured neither rateLimit nor lockout; opt out of it with null too. The three sign-in limiters — login, twoFactor, passkeyAuth — count attempts, and a success refunds the slot its request took: behind a shared address they brake failures only, and twenty colleagues signing in from one office spend nothing. A refund, not a reset — a success gives back exactly what it cost, so an attacker with an account of his own cannot clear the address's budget between guesses at other accounts. The re-auth keys (changePassword, changeEmail, deleteAccount, twoFactorDisable) take the account password too but count every request: they sit behind a session and are used once in a while, never in a sign-in wave.

key window max why this number
login 15 min 5 the one endpoint offering a human-chosen secret
changePassword · changeEmail · deleteAccount · twoFactorDisable 15 min 5 re-auth endpoints accept the account password; a hijacked session must not get a better budget than the login form, and failed re-auths feed no lockout
twoFactor 15 min 10 10⁶ codes — a few typos, online brute force hopeless
register · forgotPassword · resetPassword · verifyEmail 15 min 10 256-bit single-use tokens, so the budget brakes cost (mail sending, PBKDF2), not guessing; more generous than login because the key is the client IP and these are once-in-a-while actions behind shared/NAT addresses. These count every request, successes included: onboarding twenty people behind one office address takes two windows — the eleventh signup within fifteen minutes gets a 429 with Retry-After, tolerable for something done once
passkeyAuth 15 min 30 two calls per ceremony (options + verify share one bucket) = 15 failed or abandoned ceremonies, a completed one refunds both calls; it also bounds the challenge store, which prunes only at the 5-minute TTL — at 30 live entries per IP, not 15, because an abandoned ceremony spends one call and leaves its entry behind
refresh 1 min 30 the short window is the point: only the explicit refresh endpoint reads it (the handle hook rotates without it), a client needs it once per access-token lifetime, and every session behind one NAT address shares the counter — 30/min absorbs a many-tab burst and forgets it a minute later instead of locking the office out for fifteen

resetPassword is the one worth understanding before you loosen it: the handler runs PBKDF2 before claiming the token, deliberately, so the claim→write window stays closed — which means one unauthenticated request with any garbage token costs a full password hash (~55 ms at the default work factor). Without a limiter, a few dozen requests per second saturate the thread pool that login's own hashing shares.

Two endpoints that read one key share one counter (verifyEmail covers both verify-email and verify-email-change; passkeyAuth covers options and verify). The counter's lifetime is the lifetime of the resolved AuthConfig object, and createAuthDeps returns a new one on every call — so call createAuthDeps once, at module scope, and pass deps around. Calling it per request hands each request a fresh config object and therefore a fresh, empty counter: measured 20 of 20 requests accepted against a configured limit of 5, where one createAuthDeps lets 5 of 20 through. (Reusing the same config literal does not help; it is the resolved object's identity that keys the counter.) A second call with a jwt.secret this process has already built a bundle for is warned about on the logger — once per secret, in production too. A persistent RateLimitStore sidesteps the whole question.

The wiring is four files: deps → hooks.server.ts (createAuthHandle) → one +server.ts per handler (createLoginHandler, …) → a <LoginPage> route.

Stage 2 — Production

Swap in createPrismaRepos + a real transport (createLettermintTransport) and turn on the additive hardening layers: csrf.doubleSubmit (only when every mutation sends the token header — see the production checklist; incompatible with remote-function / no-JS-form mutations), refreshToken rotation (+ a refresh route stub via createRefreshHandler), per-handler rateLimit, lockout, and HTTPS (which auto-enables HSTS). The three single-use tokens the package mails out have their own windows under tokenTtlemailVerification (default '24h'), passwordReset and emailChange (default '1h') — in the Ns | Nm | Nh | Nd grammar every other duration field uses; set them when a policy prescribes a different one. Each is resolved when the handler is created, so a malformed value throws at wiring time rather than in a request, and shortening one never shortens a link that is already in someone's inbox. appUrl must be the real public origin — outbound email links are built from it, never from request.url (the Host header is attacker-controlled). Work through the Production-Readiness Checklist before going live.

Stage 3 — Advanced

  • Custom persistence adapter — see the Adapter Authoring Guide; validate it against the exported conformance suite.
  • Post-login deep links — the route guard preserves the originally requested path: an unauthenticated GET navigation redirects to ${loginPage}?redirectTo=<path+query> (non-GET requests redirect without the param — a POST target must not be re-issued as a GET). The param is attacker-writable like any query string, so consume it only through the exported sanitizeRedirect (root and server export), which admits internal absolute paths and falls back on everything else (absolute/protocol-relative URLs, /\ variants): onSuccess={() => goto(sanitizeRedirect(page.url.searchParams.get('redirectTo'), '/dashboard'))}.
  • JWT key rotationjwt.keyId + jwt.previousSecrets roll the signing secret without invalidating live sessions.
  • Passkeys (WebAuthn) — mount the createPasskeyHandlers(deps, webauthn) route group (the four ceremony groups plus list/item for <PasskeyManager>'s list, rename and remove — item carries both DELETE and PATCH on …/[credentialId]; requires deps.repos.passkey, throws at wiring time without it) with a webauthn: WebAuthnConfig (persistent challengeStore at >1 instance; user verification is enforced by defaultrequireUserVerification: false opts out for authenticators that cannot do UV, and because passkey logins skip the TOTP gate, that opt-out alongside config.twoFactor makes a passkey login single-factor for a TOTP-enrolled user; createPasskeyHandlers warns at wiring time when it sees the pair) and add <PasskeyManager> + <LoginPage mode="both">.
  • Notifications & Web Push — register domain events server-side, listen with <NotificationListener> + <NotificationCenter> client-side. notification.url is untrusted at navigation time — validate/allow-list it before goto(). The list/mark-read/read-all/delete routes that createNotificationStore calls are served by createNotificationsHandlers(service) — mount its list/read/readAll/item groups under the store's apiPath (default /api/notifications); every method derives userId from locals.user and goes through the ownership-scoped service methods (IDOR-safe by construction). Endpoint takeover is key-gated: pushSubscription.create upserts by endpoint, and reassigning the row to a different account requires the submitted keys to match the stored ones (compared constant-time on the decoded bytes) — the legitimate user-switch-in-the-same-browser case re-sends the browser's existing subscription (same endpoint and keys), while merely knowing the endpoint URL (say, from a log) is refused with 409 and no write ('rejected' outcome; all four outcomes — created/updated/reassigned/rejected — are conformance-tested). Endpoint URLs are still worth keeping out of logs (they are the push target), but takeover no longer rides on them alone. recipients: 'online' is presence-based, not account-based — it reaches the users with an open SSE stream in this process at send time, and nobody else (renamed from the misleading 'all'; send() throws a migration error on the old value). The registry rejects duplicate keys and the legacy 'all' value at registration time — note for dev-HMR setups: if you cache the registry on globalThis to survive hot reloads, module-scope register() calls re-run against the surviving instance and throw; cache the registrations together with the registry (or guard with registry.get(key)). A type declared with recipients: 'admins' requires a resolveAdminRecipients resolver on createNotificationService (e.g. () => repo.findAdminUserIds()) — the package has no role model of its own, so without it send() throws rather than silently delivering admin alerts to nobody. Push delivery failures are swallowed so one bad subscription can't break a send; pass onPushResult to observe them, and subscriptions the push service reports as gone (410/404) are pruned automatically. The preference/push-subscription endpoints are rate-limited per user by default (30/min PUT resp. 10/min POST+DELETE; rateLimit: null opts out), createPreferencesHandler requires the registry and rejects unregistered typeKeys (bounding per-user preference rows to the registered types), and stored subscriptions are capped per user (maxSubscriptionsPerUser, default 10, 409 beyond — re-subscribes of a known endpoint always pass).
  • Invitations (admin)createInvitationHandlers(deps, { authorize, roles }) is the server half of <InvitationManager>: mount POST + GET on /api/invitations and DELETE on /api/invitations/[id]. authorize is required and fail-closed (no open default) — registration's account-enumeration defense holds only while invitations stay admin-minted, so any authenticated user must not be able to mint one. roles allow-lists the assignable roles, so a crafted request can't escalate past what the UI offers. Every invitation carries a one-time token (SHA-256 in the database, raw value returned once) and an expiry (invitationTtlMs, default 7 days). The 201 returns inviteUrl${appUrl}/auth/register?token=…&email=… — because that URL is the only way the invitation reaches anyone when no mail transport is configured, a case this package ships a console transport for. It is not in GET: the server keeps only the hash, so an admin who loses the link revokes the invitation and mints a new one. When the client sets sendEmail, the handler also mails that link — best-effort: a mail failure still returns 201 with emailSent: false (logged), since the invitation row is the durable effect. Override the mail via inviteEmail.
  • Pre-verified invited signups — an invitation that was emailed proves mailbox ownership: the mail went to that address, it carried a secret, and the secret came back. A link the admin copied out of the panel proves nothing of the sort — it travelled whatever channel the admin chose — so autoVerifyInvited is honoured only for invitations with an emailedAt, and a copy-link signup gets the ordinary verification mail regardless of the setting. Pass createRegisterHandler(deps, { autoVerifyInvited: true }) to create the account with emailVerified: true and skip the verification token and the verification mail entirely. Without it, the user — who is auto-logged-in on register — receives a "verify your email" mail after already being signed in, and emailVerified never flips. Defaults to false (backwards-compatible: token + mail issued as before, for consumers that genuinely gate on emailVerified). The flag is an option on the register handler's second argument, not something threaded through createAuthHandle — that hook only resolves the session and guards routes; every handler is mounted with its own +server.ts. Email change still verifies the new address independently (createVerifyEmailChangeHandler); there is no prior proof of ownership for it, so it is unaffected.
  • Session enrichment — attach app-specific data (tenant/household id, plan, entitlements) to locals.user via config.hooks.transformUser(user, event). It runs in the handle hook on every authenticated request with the sanitized AuthUser (never the password hash) and the request event; its return value becomes locals.user (type it through your App.Locals). AuthUser is intentionally fixed and the loaded row carries no custom columns, so load extras here keyed by user.id rather than adding a second handle that re-resolves the session. A throw fails the request — except on the transparent refresh-rotation path, where the rotation has already committed and its cookies would be lost with it; there the throw is caught and the request continues unauthenticated. Whatever you return lands on locals.user — keep secrets out of it if locals.user is serialized to the client. Throw semantics across config.hooks: transformUser and onBeforeAccountDelete are the only two gates, where a throw aborts; every other hook only reports, so a throw is caught and sent to config.logger.error and the handler's outcome stands. Each hook's JSDoc says which it is — read that before wiring one, and don't generalize from these two.
  • Account management (self-service) — let a signed-in user manage their own account. Five server handlers (createChangePasswordHandler, createChangeEmailHandler, createVerifyEmailChangeHandler, createUpdateProfileHandler, createDeleteAccountHandler) plus the <AccountSettings> component (mount the first four under /api/auth/account/*; the verify-email-change route sits behind the link mailed to the new address). Invariants: every mutation except profile is re-auth gated (verifyCurrentPassword); change-password bumps tokenVersion + revokes all refresh families, then re-establishes the current device (others log out — a voluntary change isn't a compromise, so the initiating device stays in, unlike reset-password which signs out everywhere); change-email verifies the new address (notice to the old one) and is account-enumeration safe (token+mail decoupled, always success: true, collision = no-op); delete-account is a hard delete (GDPR erasure) that fires hooks.onBeforeAccountDelete with the sanitized user before the row is removed (a throw aborts), with the Prisma adapter dropping sent invitations in the same $transaction. Config: rateLimit.{changePassword,changeEmail,deleteAccount}, hooks onEmailChangeRequested / onEmailChangeFailed / onEmailChanged / onBeforeAccountDelete (onEmailChangeFailed surfaces the decoupled token/mail failure, like onPasswordResetFailed).
  • Active-session listing — let a user see and revoke their sessions. Requires config.refreshToken rotation — a "session" is a refresh-token family, so without rotation there is nothing server-side to list (the endpoint returns available: false). One route group — createSessionsHandlers(deps) with list (GET), revoke (POST) and revokeOthers (POST) — plus the <SessionManager> component. Each session row carries the user-agent (the component parses it to "Browser · OS" with a zero-dep heuristic) and a createdAt-based "last active"; the IP is listed only when the consumer opts in via config.sessions.storeIp (GDPR, default off). Revokes are ownership-scopedrevokeFamilyForUser returns 404 for a family that isn't the caller's, so a guessed family id can't sign out another user (IDOR). The session of the current request is flagged current (resolved from the request's refresh cookie). Login/register/passkey-verify tag their sessions with the device metadata via resolveSessionMeta, carried across rotation.
  • Two-factor authentication (TOTP) — an opt-in authenticator-app second factor on top of the password. Set config.twoFactor (encryptionKey required — high-entropy, stable; it encrypts the secret at rest, and rotating it locks every enrolled user out until they get back in with a backup code — or, for users who enrolled one, a passkey, since passkey logins are not TOTP-gated — see the key-rotation runbook) and provide repos.backupCode (both shipped adapters include it). One route group — createTwoFactorHandlers(deps): setup/enable/disable (authenticated, mount under /api/auth/account/2fa/*) plus verify (the public second login step, mount under /api/auth/2fa/verify); the login handler gates automatically on user.totpEnabled. UI: <TwoFactorManager> for enrol/disable + the two-step <LoginPage>. Invariants: the secret is AES-256-GCM-encrypted at rest and only ever returned (plaintext Base32 + otpauth URI) by setup; the login gate keys on totpEnabled alone so a missing config.twoFactor can never become a bypass (fail-closed) — it issues a signed, short-lived pending-2FA token (cookie urbicon_2fa, __Host--prefixed unless a cookieSecure: false on the session, CSRF or refresh cookie declares the deployment non-HTTPS) instead of a session, leaving no session/refresh cookie and deferring onLoginSuccess to verify; verify accepts a TOTP or a single-use SHA-256 backup code, is strictly rate-limited (createAuthDeps injects a default), and consumes the pending cookie only on success (a wrong code keeps it for retry within the TTL); disable is password re-auth gated and clears the secret + every backup code; backup codes are cleared before re-issue at enable so a re-enrol leaves none doubly-redeemable. Passkey logins are not gated — a claim that rests entirely on user verification: webauthn.requireUserVerification defaults to true, so an accepted assertion carries possession and an asserted PIN/biometric (asserted, not attested — see Known Limitations). Set it to false and a passkey is possession alone, which makes a passkey login a single-factor login for exactly the users who enrolled a second one (createPasskeyHandlers warns on that pairing). The TOTP core (server/totp.ts, RFC 6238/4226/4648) is exported for custom flows; the pending-token/cookie/backup-code plumbing (server/two-factor.ts) stays internal (consumed only by the login + 2FA handlers). Config knobs: twoFactor.{issuer,algorithm,digits,period,window,pendingTokenTtl,backupCodeCount}, rateLimit.twoFactor. algorithm defaults to SHA-1 — the only one Google/Microsoft Authenticator reliably support; the secret's high entropy carries the security, not the hash, so SHA-256 stays opt-in. Note: the verify rate-limit is per-IP (like login) and TOTP codes are replayable within their window in v1 — both deliberate trade-offs (see Known Limitations).
  • Federated identity / SSO — one deployment becomes the identity provider (ES256 tokens + createJWKSHandler), sibling apps verify with createFederatedAuthHandle and decide access themselves. See Federated Identity (SSO).
// Server: register a domain event (Stage 3 notifications)
import { createNotificationRegistry } from '@urbicon-ui/auth/server';
const registry = createNotificationRegistry();
registry.register({
  key: 'order_shipped',
  title: (data) => `Order ${data.orderId} shipped`,
  url: (data) => `/orders/${data.orderId}`, // untrusted at click time — validate before goto()
  recipients: async (data) => [data.userId as string] // data is Record<string, unknown>
});

Machine callers

A cron runner posting with a secret header, an OAuth token endpoint, an API-key route: callers that send no Origin header and hold no session cookie. The @urbicon-ui/sveltekit-utils cron runner is one — it sends its secret header and nothing else. Step 1 of createAuthHandle is an Origin gate on every mutating request, so such a POST answers 403 csrf_failed before the endpoint runs. Turning the job into a GET is not the fix (it gives up the mutation guard everywhere else), and neither is routing the endpoint around the hook. Declare it:

import { createAuthHandle } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';

export const handle = createAuthHandle({
  config: authDeps.config,
  repos: authDeps.repos,
  csrf: { exempt: ['/api/cron/', { path: '/oauth/token', exact: true }] }
});

csrf.exempt takes the publicRoutes vocabulary — a string is a pathname prefix, { path, exact: true } that pathname alone, matched against the requested pathname (a reroute hook changes which route is resolved, not this) — or a predicate over the event for callers a path does not identify: (event) => event.request.headers.has('x-cron-secret'). Two entries are refused at construction: a bare '/', because nothing would be left on (exempt: () => true is the deliberate spelling), and anything covering /api/auth/, the package's own endpoints — for the reason below. An exempt request is handled as cookieless by the hook, not merely as CSRF-skipped. The argument that makes the exemption safe — nothing ambient rides along, so there is nothing for a cross-site page to forge — holds only for a request that carries no credential the browser sends on its own, so the hook reads no cookie and writes none: no Origin gate, no session hydration, no refresh rotation, no CSRF cookie, no route guard. locals.user is null even when a valid session cookie is present — deliberately — and the response still gets the security headers. That describes the hook, not the request: the cookie still arrives, and a route that reads event.cookies itself keeps working with the gate off — which is how every handler this package ships resolves its user (requireSessionUser, from the session cookie, never from locals.user). So never exempt a cookie-authorised route; exempt only routes that authenticate every request without a cookie (bearer token, secret header, PKCE). The list form is checked for /api/auth/; the predicate form cannot be, and the rule is yours to keep there. Remote-function requests are never exempt on either transport — their pathname is client-controlled — so the remote guard's default-deny stands, and the predicate is not consulted for them.

SvelteKit's own kernel CSRF gate is a separate, earlier gate that csrf.exempt does not reach. It runs before any hook, in built apps only (never under vite dev), on form content typesapplication/x-www-form-urlencoded, multipart/form-data, text/plain, plus Kit's internal application/x-sveltekit-formdata — and 403s a cross-origin or Origin-less form POST with "Cross-site POST form submissions are forbidden". A JSON or body-less POST (MCP JSON-RPC, dynamic client registration, a cron job that sends no body and no content type) passes it; an OAuth token endpoint does not, because RFC 6749 §4.1.3 mandates form encoding and real clients send no Origin. The gate cannot be switched off per route from a hook, and its allow-list — kit.csrf.trustedOrigins — is consulted only when an Origin header is present, so no list of origins admits a header-less caller. The off-switch is kit.csrf: { trustedOrigins: ['*'] } in svelte.config.js, resolved at build time (Kit derives csrf_check_origin = checkOrigin && !trustedOrigins.includes('*') in write_server.js; the runtime check never matches '*' against a header), and since Kit 2.61 the only non-deprecated spelling (checkOrigin is deprecated in its favour). Kit's strict same-origin check for remote-function requests is independent of kit.csrf.* and stays on. Disabling the kernel gate is safe exactly when (1) every cookie-authenticated mutating route flows through createAuthHandle — including form actions such as an OAuth consent ?/approve, and "cookie-authenticated" means any ambient-session route, not only this package's; reaching the hook is what counts, so a handle earlier in a sequence() that answers without calling resolve (maintenance mode, a webhook implemented as a handle, a redirect) leaves its routes uncovered — and (2) csrf.exempt names only routes that authenticate themselves. Step 1 is stricter than the kernel gate for everything that reaches it (every mutating method, every content type, no allow-list), so the kernel check was redundant there; if you relied on trustedOrigins as an allow-list for legitimate cross-origin browser form posts, note that validateCsrf has no equivalent. On a federated consumer the wildcard is off-limits without a validateCsrf backstop of your own — see Limitations.

Upgrade note — user verification is enforced by default

webauthn.requireUserVerification defaults to true. Registration and assertion both reject an authenticator that does not set the UV bit, so credentials already enrolled on a UV-less authenticator stop working on upgrade: a security key with no PIN configured, or one driven in a mode that only proves User Presence. Platform authenticators (Touch ID, Windows Hello, Android) set the bit and are unaffected.

What it looks like when it bites: the assertion answers 400 with the bare passkey_verification_failed code — the body names no cause (see Error Contract) — and hooks.onLoginFailed fires with the reason invalid_assertion and an empty email. The cause is only in config.logger: [auth] passkey assertion rejected: User verification required but not performed. In an audit sink the hook entry reads like an attack, not like a config change — so if you upgrade and your passkey-failure rate jumps, look for that log line before you look for an attacker. Enrolling a new credential on the same authenticator fails the same way, logged as [auth] passkey registration verification failed: User verification required but not performed.

Two ways out: have the affected users set a PIN on the key (the outcome the default is asking for), or set requireUserVerification: false — in which case read the 2FA note on the passkey bullet above before you do, because with config.twoFactor wired the opt-out makes a passkey login single-factor.

Key-rotation runbook (twoFactor.encryptionKey)

The 2FA secret is encrypted at rest with this key, and decryptSecret is fail-closed. Changing the key therefore does not degrade — it locks out every user with totpEnabled, and re-enrolment is not the way back: setup refuses while 2FA is still on, so those users cannot mint a secret under the new key either.

Two routes remain open. A backup code — the verify handler keeps that path live because backup codes are hashed on their own rows and need no TOTP secret. And a passkey, for users who enrolled one: passkey logins are not TOTP-gated (see the passkey bullet above), so the rotated key never touches them. Either route yields a session, and a session is all that disablesetupenable needs to re-enrol under the new key.

Both decryption failures are reported through config.logger at error level, naming a user id — but not at the same rate. verify reports every attempt (that endpoint is rate-limited by default); enable reports only the first attempt per user, because it has no limiter and one authenticated caller could otherwise fill the sink. So the enable lines count the users the rotation reached, not the attempts they made. Watch that sink: the response is a 500 totp_secret_unreadable built with authError, which returns a Response rather than throwing error(), so SvelteKit's handleError never fires and a Sentry integration hooked there sees nothing.

  1. Do not rotate on a schedule. Unlike jwt.previousSecrets, this key has no overlap mechanism — one key is in force at a time.
  2. If you must (key compromise): announce it, then rotate. Every TOTP user whose only other credential is the authenticator app needs a backup code at the next login; passkey holders sign in unaffected.
  3. After the rotation, drive the affected users through disablesetupenable (the <TwoFactorManager> flow) so their secret is re-encrypted under the new key and a fresh backup-code set is issued.
  4. A user out of backup codes and without a passkey is locked out of their account; recovery is your own out-of-band identity proof plus an administrative disableTotp. Check for an enrolled passkey before running that ceremony — it is the cheaper route and the user can walk it alone.
  5. If the key was merely lost (not compromised), restoring it from backup is the whole fix — nothing on the user rows changed.

Federated Identity (SSO)

One deployment running this package becomes the identity provider (IdP); any number of sibling apps become consumers that trust it. The token boundary is deliberately narrow:

Identity ≠ authorization. The IdP's JWT proves who the caller is — nothing else. Each consumer app decides whether that identity gets in, and as what, in its own resolveUser. The IdP token's role and tokenVersion claims are IdP-application-internal and are structurally withheld from consumers (FederatedIdentity carries only subject, email, issuedAt, expiresAt — type and runtime). Forwarding the IdP's role would be exactly the identity/authorization confusion this design forbids: an "admin" at auth.example.com is not an admin of your billing app.

Architecture (prose diagram)

  • IdP (auth.example.com): switches its session JWT to jwt.algorithm: 'ES256' (ECDSA P-256; HS256 stays the default for non-federated deployments) and publishes the public half of its signing key as an RFC 7517 JWKS via createJWKSHandler — served with Cache-Control: public, max-age=300. Login, logout, registration, refresh rotation, 2FA: all stay exclusively at the IdP. With jwt.cookieDomain: '.example.com' the session cookie is shared with every *.example.com sibling (the refresh and CSRF cookies deliberately stay host-scoped — consumers verify, they never rotate).
  • Consumer (app.example.com): mounts createFederatedAuthHandle as its hooks.server.ts handle. Per request it verifies the shared cookie's ES256 signature against the IdP's JWKS (fetched lazily, cached ~5 min, cooldown-limited refresh on unknown kid — no fetch storms; fetch failures fail closed with one loud log line, never a 500), then calls resolveUser(identity, event). The result is locals.user (same locals contract as createAuthHandle); null denies. Route guard: publicRoutes entries pass (a string prefix or { path, exact: true }, read exactly as on the IdP handle), /api/ gets a JSON 401, pages 302 to loginUrl (typically the IdP login; used verbatim — no redirectTo, since the IdP's sanitizeRedirect admits IdP-local paths only), and unauthenticated remote-function calls are default-denied on both transports (the same default-deny as the IdP handle). The consumer handle never writes cookies (the cookie is the IdP's — clearing it would log the user out of every sibling app), runs no CSRF gate of its own (keep SvelteKit's kernel CSRF gate on — the default; do not set trustedOrigins: ['*'] on a federated consumer) and sets no security headers (your app's own policy).
  • Why ES256 only: a federated consumer must verify asymmetrically. Verifying HS256 requires the signing secret — and a shared signing secret means every "consumer" can also mint tokens, i.e. there is no trust boundary left. The consumer pins alg: ES256 (plus a mandatory kid) before touching any key material; the IdP side pins its configured algorithm the same way (no algorithm-confusion downgrade in either direction).
  • Purpose binding: every JWT this package mints carries a purpose claim, and every verifier requires it in the primitive — not in each caller's claim-shape check. Session tokens (HS256 and ES256 alike) are stamped purpose: 'session' (exported as SESSION_TOKEN_PURPOSE); verifySessionToken and the consumer handle reject any token without exactly that value. The generic short-lived tokens (createSignedToken/verifySignedToken — e.g. the pending-2FA handle, '2fa-pending') take a mandatory purpose parameter, stamped at mint and matched verbatim at verify (purpose is a reserved claim; a missing/empty purpose argument throws — API misuse, not a bad token). Result: two token kinds signed with the same jwt.secret can never be accepted for each other's purpose. Version skew: the claim crosses the app boundary, so upgrade the IdP first — a pre-purpose consumer ignores the extra claim, while an upgraded consumer rejects pre-purpose IdP tokens (fail-closed; the break heals as tokens re-mint via login/refresh after the IdP upgrade).
  • Verifier input cap: verifySessionToken, verifySignedToken and the consumer handle reject input longer than MAX_TOKEN_LENGTH (8 KB — double the ~4 KB browser cookie ceiling, so a legitimate cookie-borne token can never hit it) before any parsing; on the consumer this also precedes any JWKS fetch. Belt-and-suspenders for verifiers applied to unbounded non-cookie input.

Setup — IdP side

  1. One-time key setup (a script, never on boot — a fresh key per process would invalidate every live session and desynchronize consumers):
    import { generateES256KeyPair } from '@urbicon-ui/auth/server';
    const { privateKey, publicKey, kid } = await generateES256KeyPair();
    // privateKey → secret manager (it contains the private scalar `d`);
    // kid is the RFC 7638 thumbprint, stamped into both JWKs.
    
  2. Config: jwt: { algorithm: 'ES256', signingKey: privateKey, secret, cookieDomain: '.example.com' }. jwt.secret stays required — package-internal short-lived tokens (pending-2FA handle etc.) deliberately remain HMAC. Misconfiguration (ES256 without a usable key, malformed previousPublicKeys) throws at wiring time in createAuthDeps/createAuthHandle.
  3. Mount the JWKS route, e.g. src/routes/.well-known/jwks.json/+server.ts:
    import { createJWKSHandler } from '@urbicon-ui/auth/server';
    export const { GET } = createJWKSHandler({ jwt: config.jwt });
    
    Exempt the route from the IdP's own guard if it falls outside your public prefixes — publicRoutes replaces the defaults rather than adding to them, so that reads publicRoutes: [...DEFAULT_PUBLIC_ROUTES, '/.well-known/']. Only public JWK members can reach the response (allow-list projection; a d in the config is warned about and never served).

Setup — consumer side

// hooks.server.ts of app.example.com
import { createFederatedAuthHandle } from '@urbicon-ui/auth/server';
import { createPrismaFederatedAccountRepository } from '@urbicon-ui/auth/server/adapters/prisma';
import { prisma } from './prisma';

const federated = createPrismaFederatedAccountRepository(prisma); // throws if the model is missing

export const handle = createFederatedAuthHandle({
  jwksUrl: 'https://auth.example.com/.well-known/jwks.json', // https enforced (http: localhost only)
  cookieName: 'session', // must match the IdP's jwt.cookieName
  loginUrl: 'https://auth.example.com/auth/login',
  publicRoutes: [{ path: '/', exact: true }, '/pricing'], // exact: that pathname; a string: a prefix
  resolveUser: async (identity) => {
    // THE authorization decision of this app — identity.subject is the stable key.
    const link = await federated.findByFederatedId('https://auth.example.com', identity.subject);
    if (!link) return null; // unknown identity → no access (fail-closed)
    return prisma.user.findUnique({ where: { id: link.userId } }); // roles are THIS app's columns
  }
});

The FederatedAccount link table (findByFederatedId / linkFederatedAccount / unlinkFederatedAccount, unique on (issuer, subject)) ships as an optional repo section in both adapters — see the Prisma Schema; the issuer string is your stable label for the IdP (canonically its origin — the token carries no iss claim, the trust anchor is the one jwksUrl you configured). Linking is idempotent for the same user and refuses re-linking a pair to a different user (account-takeover primitive); the explicit path is unlinkFederatedAccount(userId, { issuer, subject })owner-scoped (one conditional delete; returns true iff a link owned by userId was removed), so the unlink→re-link two-step cannot take over a foreign identity either. Onboarding flows (invite-based, email-match against identity.email, admin-approved) are the consumer's own policy in resolveUser. Consumers without a database can resolve users without any repo — resolveUser is just a function.

Key-rotation runbook (previousPublicKeys)

  1. Generate the next pair: const next = await generateES256KeyPair().
  2. On the IdP, deploy in ONE step: signingKey: next.privateKey, and move the public JWK of the retiring key into jwt.previousPublicKeys (it already carries its kid; for a hand-built entry derive it with computeJwkThumbprint). The JWKS now serves both keys; old sessions keep verifying, new tokens carry the new kid.
  3. Consumers converge automatically: an unknown kid triggers a (cooldown-limited) JWKS refresh, so the new key propagates within about a minute — no consumer deploy.
  4. After the old sessions have expired (jwt.expiresIn, default 7d), remove the retired entry from previousPublicKeys. Removal is the kill switch: tokens signed by a removed key fail verification everywhere.

Limitations (deliberate v1 scope)

  • Revocation blindness: consumers cannot see the IdP's tokenVersion bumps ("log out everywhere"), so an IdP-revoked session stays verifiable at consumers until exp. Bound the window with short-lived IdP access tokens (refreshToken rotation keeps UX intact) and/or the consumer-side maxTokenAge option; emergency global kill = rotate the signing key without keeping the old public key.
  • Same trust domain: cookie-based federation needs a shared parent domain (cookieDomain). Cross-domain SSO (redirect-based token handoff, OIDC) is out of scope — as is acting as an OAuth/OIDC provider for third parties (see the auth package non-goals).
  • Logout is IdP-global: the consumer never clears the shared cookie; "sign out of one app only" does not exist in this model (deny via resolveUser instead).
  • Sibling subdomains are full trust peers: the shared-domain session cookie is readable and settable by every host under cookieDomain. A compromised sibling (evil.example.com) can (a) replay the bearer JWT it receives to any other sibling until exp, and (b) set a .example.com session cookie to pin a victim onto an attacker-chosen — but validly signed — session (cookie-tossing / session fixation). Put only apps you trust as peers under one cookieDomain, and give each sibling's CSRF cookie a __Host- prefix so it can't be tossed across subdomains.
  • The consumer handle runs no CSRF gate: unlike createAuthHandle, createFederatedAuthHandle does not check request origin. SvelteKit's kernel CSRF gate covers form-encoded cross-site POSTs but not JSON ones (see Known Limitations), so a cookie-authenticated JSON mutating endpoint on a consumer must enforce its own CSRF defense. The trustedOrigins: ['*'] resolution from Known Limitations is off-limits on a federated consumer unless you build the backstop yourself: there is no validateCsrf behind this handle, so ['*'] would leave every cookie-authenticated form mutation of the app unprotected. A federated consumer that must expose its own header-less cross-origin endpoint (own OAuth token endpoint, webhook) first gates its cookie-authenticated mutations by calling the exported validateCsrf(event.request, event.url) in its own hook (sequenced before createFederatedAuthHandle), exempting its machine routes there. What that hook cannot give is the cookieless handling createAuthHandle gives csrf.exempt: createFederatedAuthHandle hydrates locals.user on every request and has no exemption seam, so an exempted route is CSRF-skipped but stays fully session-hydrated — it must therefore not be cookie-authorised (bearer token, secret header, PKCE only), and it still has to be listed in publicRoutes, or the federated guard answers 401 before the route runs. What the kernel gate still does to such a route and when its off-switch is safe: Machine callers. Only then disable the kernel gate.

Prisma Schema

The reference schema ships in the package, at node_modules/@urbicon-ui/auth/prisma/auth-schema.prisma (packages/auth/prisma/auth-schema.prisma in this repo). Ten models: User, Invitation, PushSubscription, Notification, NotificationType, NotificationPreference, Passkey, RefreshToken (rotation; required once config.refreshToken is set), TwoFactorBackupCode, FederatedAccount (consumer-side federation link, optional). The User model carries the 2FA columns totpSecret (AES-256-GCM-encrypted), totpEnabled, totpConfirmedAt. The reference schema puts onDelete: Cascade on all eight dependent models, Invitation.invitedBy included. The delete-account handler still removes sent invitations by hand inside its $transaction, so an adapter over a schema without that cascade behaves identically; the conformance suite pins both — the hand-written half directly, the rest through user.delete erases the dependents of every declared repository.

A model your client does not have drops its feature to undefined. That is the design — createPrismaRepos wires what exists — but the absences differ in how visible they are. passkey makes createPasskeyHandlers throw at wiring; refreshToken makes both wiring entry points throw whenever config.refreshToken is set; twoFactorBackupCode makes the 2FA handlers answer feature_unavailable 400; notification is a type error, because the notification service requires that repo. The two that nothing else surfaces are pushSubscription (web-push delivery is skipped for every notification — the DB row and the SSE event still happen) and notificationPreference (per-user channel preferences are ignored and notifications go out on every channel the type declares). Every absent model is reported once per Prisma client at factory time. The report defaults to console — the same sink config.logger ?? console resolves to — so pass createPrismaRepos(prisma, { logger }) when the app configures its own config.logger, to keep the wiring diagnostics with the rest of the auth logs. Note the dedup key is the client, not the logger: whichever call comes first wins, so wire the logger on the first one.

Upgrading an existing database

The Invitation model gained three columns: tokenHash (@unique, NOT NULL), expiresAt (NOT NULL) and emailedAt. Two of them have no default, and that is deliberate — an invitation minted under the old rules is redeemable by anyone who knows the address, which is the hole this closes.

Every unused invitation becomes invalid. The admins re-invite — which no longer needs a mail transport, since the panel now hands back a copyable link.

Adding two NOT NULL columns to a table that already has rows does not work in one statement, and a constant backfill collides with the unique index. Add them nullable, fill per row, then tighten — the shape below runs on PostgreSQL as written and translates directly to other engines:

-- 1. Unused invitations are the ones that must not survive: under the old rules
--    they are redeemable by anyone who knows the address.
DELETE FROM "Invitation" WHERE "usedAt" IS NULL;

-- 2. Add nullable, so existing (already-redeemed) rows are accepted.
ALTER TABLE "Invitation"
  ADD COLUMN "tokenHash" TEXT,
  ADD COLUMN "expiresAt" TIMESTAMP(3),
  ADD COLUMN "emailedAt" TIMESTAMP(3);

-- 3. Backfill the surviving history. The values are inert — these invitations
--    are spent — but `tokenHash` is UNIQUE, so a constant will not do. Anything
--    per-row and unguessable works; the id is both.
UPDATE "Invitation"
   SET "tokenHash" = 'migrated:' || "id",
       "expiresAt" = "createdAt"
 WHERE "tokenHash" IS NULL;

-- 4. Now the constraints hold.
ALTER TABLE "Invitation"
  ALTER COLUMN "tokenHash" SET NOT NULL,
  ALTER COLUMN "expiresAt" SET NOT NULL;
CREATE UNIQUE INDEX "Invitation_tokenHash_key" ON "Invitation"("tokenHash");

Redeemed rows are kept because the admin panel lists them (they render as Registered); their backfilled expiresAt sits in the past and their tokenHash matches no real token, so neither is redeemable.

Adapter Authoring Guide

Persistence is behind a repository interface (Repositories in packages/auth/src/lib/server/adapters/types.ts). The auth core never talks to a database directly — it talks to repos.user, repos.refreshToken, etc. This section is the contract for writing your own adapter.

What we ship, and the strategy

Adapter Import Use
Prisma @urbicon-ui/auth/server/adapters/prisma (createPrismaRepos) Production reference. Copy the models from the shipped prisma/auth-schema.prisma.
In-memory @urbicon-ui/auth/server/adapters/in-memory (createInMemoryRepos) Dev/test only — heap Maps, single-process, wiped on restart. The five-minute quickstart and the test fixture. createInMemoryRepos() is a fresh createInMemoryStore() with every repository built on it; take a piece of it — say the refresh-token repository beside a user store of your own — by building that factory on a store handle (createInMemoryRefreshTokenRepository(createInMemoryStore())). Repositories on one store share its rows, and user.delete erases a user's dependents from every table of that store. The store carries the role type — createInMemoryStore<'ADMIN' | 'USER'>() — and every factory infers it from the handle; a role-typed factory on an untyped store is a type error.
Conformance suite @urbicon-ui/auth/server/adapters/conformance (describeRepositoryConformance) Not an adapter — the executable contract every adapter validates itself against. Wired to vitest; under another runner import …/conformance-core and pass { runner: { describe, it, expect } }.

We deliberately do not ship one official adapter per ORM (Drizzle, Kysely, …). Each interface change would then have to be maintained × N adapters. Instead the interface is the contract and the conformance suite makes a third-party adapter provably safe rather than hopefully-safe. A Drizzle worked example is below; ship it in your own app, validate it with the suite.

The contract: what every adapter MUST guarantee

The interface JSDoc (types.ts) is authoritative; these are the invariants that are easy to get wrong. Getting any of these wrong is a security bug, not a style nit — a non-atomic token claim double-spends a single-use reset link; an unscoped delete lets one user silence another's security notifications.

Operation Guarantee Why
refreshToken.revoke(id) Compare-and-set: flip revokedAt null→now only if still live; return true iff this call won (count === 1). Rotation race-safety — two concurrent rotations must yield exactly one live successor.
refreshToken.revokeFamilyForUser(userId, family) Revoke the family in one write scoped to userId (UPDATE … WHERE family = ? AND userId = ?); return true iff a row the caller owns was revoked, else false (→ 404/no-op). Never revoke by family alone. IDOR — a guessed family id must not let one user sign another user out of their session.
refreshToken.listActiveByUser(userId) Return only live rows: revokedAt null and unexpired. The rotation's race tolerance reads it: a spent token presented inside the ten-second grace window passes as the loser of a concurrent rotation only while its family still has a live token here — after revokeFamily / revokeAllForUser it is refused. A revoked row reported live would keep a "sign out everywhere" open for ten seconds.
user.consumeResetToken / consumeVerificationToken Single conditional write that clears the token; return the user only on the winning claim, else null. Purge an expired token's hash. A null stored expiry reads as no deadline in both shipped adapters — write the expiry the setter handed you, or that token stays claimable forever. One reset/verify link → one use; no double-spend under concurrency.
user.consumeEmailChangeToken Single conditional write that swaps emailpendingEmail and sets emailVerified, clearing the pending fields — only if the token matches, is unexpired, and the target address is still free. A collision with another account is a failed claim (null), never a duplicate email. Purge an expired token's artifacts. One email-change link → one use; respects email uniqueness under concurrency.
invitation.findByTokenHash(hash) Look up by SHA-256 of the token. Returns used and expired rows too — the handler tells the three cases apart. This is the registration gate; knowing an address is not.
invitation.markEmailed(id, at) Record that the invite mail went out. No-op for a missing id. emailedAt is what allows autoVerifyInvited to skip verification.
invitation.markUsedIfUnused(id) CAS: flip usedAt null→now; true iff this call won. One invitation → one registration.
backupCode.consumeIfUnused(userId, hash) Single conditional write scoped to userId that flips an unused code to used; true iff this call won. Never SELECT then UPDATE. One backup code → one redemption; no cross-user redemption (IDOR).
user.incrementTokenVersion Atomic increment, never read-modify-write. Parallel "log out everywhere" must not lose an increment.
user.recordFailedLogin(id, lock?) Atomic increment; read the new count from the write. The threshold and the lock instant arrive as values (lock.maxAttempts, lock.lockedUntil, resolved by the login handler): compare against the one, store the other verbatim — never a threshold or duration of your own; lockedUntil is stored at least to the second, and the suite tolerates rounding under one second. Set the lock with a write guarded on the DB-side count (failedLoginAttempts >= lock.maxAttempts), not the value just read. Stamp the attempt's time, and hand it back as lastFailedAt from getFailedLoginAttempts. A store that cannot keep that column returns null there — never a stand-in timestamp. Lockout must not under-count under credential stuffing, nor set the lock from a stale count. Time arithmetic lives in one place: an adapter with numbers of its own silently overrides the configured policy (the suite hands in values the defaults cannot reproduce). lastFailedAt is what dates the count: null costs the decay (counters then only fall on a successful sign-in, the pre-decay behaviour), a stand-in would date an old count as current and hand out a fresh set of attempts.
user.resetFailedLoginsIfStale(id, cutoff) The clear of resetFailedLogins, guarded in the store: one conditional write, UPDATE … WHERE id = ? AND lastFailedLogin <= ?. A row without a lastFailedAt matches nothing. The login handler decays a count it read as stale, and that write can land after other requests counted failures and set a lock; each of those re-dates the row past the cutoff. Applied unguarded, the decay becomes a way to wipe a live lockout.
passkey.updateCounter(id, n) CAS: bump only if stored < n; false if nothing advanced. n === 0 → counterless, touch lastUsedAt only. Cloned-authenticator detection.
notification.markAsRead/delete, pushSubscription.delete, passkey.delete/rename Scope every mutation to the owner userId. A non-owner call must not mutate (no-op or throw both fine). IDOR — knowing an id/endpoint must not let an attacker touch another user's row.
pushSubscription.create Upsert-by-endpoint: a row with the same endpoint is updated in place. Reassigning it to a different user is key-gated: only when the submitted keys equal the stored ones (constant-time on the decoded bytes) — otherwise return 'rejected' without writing. Never fail on the unique endpoint. Re-enabling push re-sends the browser's existing subscription (the duplicate POST is the normal case); key possession is what separates the legitimate user switch from an endpoint-URL takeover.
user.delete Hard-delete plus dependents: rely on onDelete: Cascade for passkeys/tokens/notifications/subscriptions/preferences/backup-codes/federated-links, and delete the invitations the user sent by hand as well (portable for a schema whose invitedBy FK has no cascade), ideally in one transaction. Conformance pins every dependent whose repository you declare — removed, not merely revoked. GDPR erasure must not leave orphans; the sent-invitations half is the part every adapter writes itself.

The CAS/claim operations are why the interface returns Promise<boolean> (or the claimed entity) rather than void: the business logic in the core (rotateRefreshToken, the register/reset/verify handlers) reads that return value to detect a lost race. Implement each as a single conditional statement (UPDATE … WHERE <still-claimable> RETURNING …), never SELECT then UPDATE across an await — that gap is the race.

Ids: opaque strings, and a miss is never an error

The package never generates or parses an id. It stores what your adapter returned and hands it back verbatim, so any id scheme works — uuid, cuid2, ULID, or an integer key rendered as a string. The shipped schema writes String @id @default(uuid()), which Prisma maps to text; you may map ids to a native type instead (String @id @default(uuid()) @db.Uuid), and you do not need to change the interface for it, because id: string describes the wire value, not the column.

One limit on the shipped Prisma adapter specifically: it passes ids through as the strings the interface gives it, so the column has to be string-shaped (text, uuid, citext). A numeric key needs an adapter that converts at the boundary — the interface still fits (render the number as a string), the shipped adapter does not do the conversion for you.

What a native id type does change is what happens to a value that does not fit it. Ids arrive from outside — a URL segment, a request body — and a uuid or bigint column rejects an unparsable value with SQLSTATE 22P02 rather than simply matching nothing, on reads as much as on writes:

SELECT … WHERE id = 'not-an-id'   -- text: 0 rows · uuid: ERROR 22P02

So the contract has two rules about misses, and both are pinned by the conformance suite:

  1. An id your store cannot represent behaves exactly like an id that is absent — return null, return false, no-op. Never throw. Catch that one error on that one argument and turn it into the miss; every other database error must keep propagating. The shipped Prisma adapter does this for you (idSafeClient in adapters/prisma.ts).
  2. A scoped mutation that matches no row is a no-op, not a throw. "Not yours" and "not there" are the same answer. In Prisma terms: updateMany/deleteMany, whose zero-match result is a count, not the P2025 that update/delete raise.

Both exist because a miss is a normal answer, and only a throw is not. What the route does with it varies — the session revoke answers 404 (it gets a boolean back), while the notification, passkey and invitation delete routes are deliberately idempotent and answer 200 whether or not a row matched — but an adapter that throws turns every one of them into a 500, so a malformed id becomes a way to fail those endpoints on demand.

The passkey rename route also answers 404 (passkey_not_found) for a row that is absent or is not the caller's. What separates it from the deletes above is not that it refuses — the session revoke refuses too — but where its answer comes from: the revoke is handed a boolean by its repository, while rename returns void, so this route reads the row itself before writing rather than inferring the outcome from a call that cannot report one. A 200 on a rename that did not happen would leave the panel showing a name the store does not hold; a delete's no-op has no such reading, because dropping the row is what the client does either way. The repository's own owner scope is still required: it is the second gate, and the only one a consumer route calling rename directly has.

Three further cross-cutting conventions round the contract off. Owner-first parameters: every owner-scoped mutation takes (userId, id, …)markAsRead(userId, id), passkey.delete(userId, credentialId), pushSubscription.delete(userId, endpoint), backupCode.consumeIfUnused(userId, codeHash). With two plain strings a swapped call still compiles, so the single fixed order is what keeps a swap greppable. Pre-normalized emails: every email reaching a repository (lookups and create data) was already trimmed + lowercased by the package's validation — match and store verbatim, never re-normalize. Feature tiers: UserRepository is sectioned by feature (core · email verification · password reset · email change · TOTP); an adapter for an app that will never mount a feature may stub that section with throwing methods, since nothing calls a section whose feature is not wired — the shipped adapters implement everything (see the section comments in adapters/types.ts).

Structural boundary — the XLike pattern

createPrismaRepos<AppRole>(prisma) accepts the consumer's generated client through a structural interface (PrismaLike). Every method returns Promise<PrismaRow> where PrismaRow = any — a single, intentional eslint-disable at the module boundary.

PrismaLike lists what the adapter actually calls, so it moves when the adapter does. It now asks for deleteMany on notification and passkey, and no longer asks for the single-row delete on notification, passkey and invitation, nor update on notification and passkey — those scoped writes moved to the …Many operations (see the id contract above). A generated client satisfies both versions; only a hand-written stand-in needs the two new methods. It buys wider version coverage in exchange: the old calls needed Prisma's extended WhereUniqueInput (update({ where: { credentialId, userId } }), GA in 5.0), while updateMany/deleteMany take an ordinary filter.

The reason is concrete: every consumer generates its own row shapes from its own schema (extra columns, RLS-only fields, soft-delete flags). Typing PrismaLike.user.findUnique against our internal User/Passkey shapes would either reject the consumer's wider rows or force a per-method generic. Both leak the adapter's internal type model into the consumer signature.

Inside the adapter, each method casts the returned row to the internal shape via a named mapX(row) seam — that's where a missing column surfaces as a TypeScript error, not as a silent any at the call site:

// XLike — permissive at the boundary
type PrismaRow = any;
interface PrismaLike {
  user: { findUnique(args: unknown): Promise<PrismaRow> /* … */ };
  passkey: { findMany(args: unknown): Promise<PrismaRow> /* … */ };
}

// Adapter method — strict at the seam
async function listPasskeys(userId: string): Promise<Passkey[]> {
  const rows = await prisma.passkey.findMany({ where: { userId } });
  return (rows as PrismaPasskeyRow[]).map(mapPasskey); // ← typed conversion
}

The mapX seams (mapUser, mapPasskey, mapRefreshToken, mapInvitation, mapNotification, mapPushSubscription, mapNotificationPreference) live next to each other at the bottom of prisma.ts. mapInvitation doubles as a security projection: invitation results feed the admin HTTP response directly, so the seam is what keeps invitedById (and consumer extra columns) out of the wire format — the conformance suite asserts the exact field set. A new adapter follows the same shape: a structural XLike interface with unknown args + one permissive return type, narrow casts inside each method, named mapX(row) helpers so the conversion is testable. Don't expose generated row types to the consumer — that's what made the original adapter sprout 32 Promise<unknown> errors cross-package.

Validate it: the conformance suite

Whatever you build, prove it upholds the contract by running the shared suite from a *.test.ts. The entry below registers vitest for you; under any other runner import …/adapters/conformance-core instead and pass { runner: { describe, it, expect } } — that module imports no runner of its own:

import { describeRepositoryConformance } from '@urbicon-ui/auth/server/adapters/conformance';
import { createMyAdapter } from './my-adapter';
import { freshTestDatabase } from './test-db';

describeRepositoryConformance('my-adapter', {
  role: 'USER',
  // All seven optional repos, `false` for the ones you do not implement — an
  // omitted key reads as "not implemented" and drops its checks silently.
  capabilities: {
    refreshToken: true,
    passkey: true,
    notification: true,
    pushSubscription: true,
    notificationPreference: true,
    backupCode: true, // set to false if you do not implement this
    federatedAccount: true // set to false if you do not implement this
  },
  // MUST hand back a fresh, isolated repo set per check (wipe schema / new tx).
  setup: () => createMyAdapter(freshTestDatabase())
});

The suite drives each atomic claim under Promise.all concurrency, asserts exactly one winner, and checks every ownership scope. It is exactly what the shipped Prisma and in-memory adapters run against in CI — including a negative control proving a non-atomic adapter is rejected, so the checks have teeth.

Read the suite title: it states how many checks ran out of how many, and names every repository you left undeclared. A check whose repository is undeclared is reported as skipped, so a short capability list is a green run that asserted less than you think — including backupCode.consumeIfUnused, whose single-use-under-concurrency guarantee is in the table above. summarizeConformanceRun(harness) returns the same numbers if you want to gate on them yourself.

Worked example — a Drizzle adapter

The same XLike + mapX + single-statement-CAS pattern in Drizzle. The trick on every claim is UPDATE … WHERE <still-claimable> RETURNING … and treating "0 rows returned" as "lost the race" — Drizzle's .returning() gives the row count for free.

import { and, desc, eq, gt, isNull, lt, lte, ne, or, sql } from 'drizzle-orm';
import type {
  FullAuthUser,
  RefreshTokenRecord,
  RefreshTokenRepository,
  UserRepository /* … */
} from '@urbicon-ui/auth/server';

// The `mapX` seam. The row shape gets its own type and the seam takes it as a
// parameter — that is the half that does the work. Against `row: any` a
// renamed column (`row.token_hash`) type-checks silently; against
// `RefreshTokenRow` it is a `TS2551` right at the seam. The shipped Prisma
// adapter types every seam this way; `any` belongs at the db boundary, not here.
interface RefreshTokenRow {
  id: string;
  userId: string;
  tokenHash: string;
  family: string;
  expiresAt: Date;
  revokedAt?: Date | null;
  replacedById?: string | null;
  createdAt: Date;
  userAgent?: string | null;
  ip?: string | null;
}

const mapRefreshToken = (row: RefreshTokenRow): RefreshTokenRecord => ({
  id: row.id,
  userId: row.userId,
  tokenHash: row.tokenHash,
  family: row.family,
  expiresAt: row.expiresAt,
  revokedAt: row.revokedAt ?? null,
  replacedById: row.replacedById ?? null,
  createdAt: row.createdAt,
  userAgent: row.userAgent ?? null,
  ip: row.ip ?? null
});
// Same seam against `FullAuthUser`, in your own file — declared here so this
// excerpt type-checks standalone.
declare function mapUser(row: any): FullAuthUser;

// `DrizzleLike` is the structural boundary — pass any drizzle db + your tables.
// All nine `RefreshTokenRepository` methods are here, and the annotated return
// type is what keeps them here: leave one out and the factory is a `TS2741`
// naming it. Annotate, never assert — see the note on the user factory below.
export function createDrizzleRefreshTokenRepository(db: any, t: any): RefreshTokenRepository {
  return {
    async create(data) {
      const [row] = await db.insert(t.refreshToken).values(data).returning();
      return mapRefreshToken(row);
    },
    async findByHash(tokenHash) {
      const [row] = await db
        .select()
        .from(t.refreshToken)
        .where(eq(t.refreshToken.tokenHash, tokenHash))
        .limit(1);
      return row ? mapRefreshToken(row) : null;
    },
    async revoke(id, replacedById) {
      // CAS: revoke only while still live; RETURNING gives the win/lose count.
      const won = await db
        .update(t.refreshToken)
        .set({ revokedAt: new Date(), replacedById: replacedById ?? null })
        .where(and(eq(t.refreshToken.id, id), isNull(t.refreshToken.revokedAt)))
        .returning({ id: t.refreshToken.id });
      return won.length === 1;
    },
    async revokeFamily(family) {
      await db
        .update(t.refreshToken)
        .set({ revokedAt: new Date() })
        .where(and(eq(t.refreshToken.family, family), isNull(t.refreshToken.revokedAt)));
    },
    async revokeAllForUser(userId) {
      await db
        .update(t.refreshToken)
        .set({ revokedAt: new Date() })
        .where(and(eq(t.refreshToken.userId, userId), isNull(t.refreshToken.revokedAt)));
    },
    async deleteExpired() {
      const gone = await db
        .delete(t.refreshToken)
        .where(lt(t.refreshToken.expiresAt, new Date()))
        .returning({ id: t.refreshToken.id });
      return gone.length;
    },
    async listActiveByUser(userId) {
      // Live sessions for the session list: non-revoked AND unexpired, newest
      // first. Rotation keeps one live token per family, so one row = one
      // session.
      const rows = await db
        .select()
        .from(t.refreshToken)
        .where(
          and(
            eq(t.refreshToken.userId, userId),
            isNull(t.refreshToken.revokedAt),
            gt(t.refreshToken.expiresAt, new Date())
          )
        )
        .orderBy(desc(t.refreshToken.createdAt));
      return rows.map(mapRefreshToken);
    },
    async revokeFamilyForUser(userId, family) {
      // revokeFamily plus the mandatory owner guard. Without the userId
      // predicate a guessed family id signs another user out (IDOR) — this is
      // the method the session-revoke route calls, and its boolean is what
      // becomes the 404.
      const revoked = await db
        .update(t.refreshToken)
        .set({ revokedAt: new Date() })
        .where(
          and(
            eq(t.refreshToken.userId, userId),
            eq(t.refreshToken.family, family),
            isNull(t.refreshToken.revokedAt)
          )
        )
        .returning({ id: t.refreshToken.id });
      return revoked.length > 0;
    },
    async revokeOtherFamiliesForUser(userId, keepFamily) {
      // "Sign out all other sessions": everything of this user except the
      // caller's own family, in one write.
      await db
        .update(t.refreshToken)
        .set({ revokedAt: new Date() })
        .where(
          and(
            eq(t.refreshToken.userId, userId),
            ne(t.refreshToken.family, keepFamily),
            isNull(t.refreshToken.revokedAt)
          )
        );
    }
  };
}

export function createDrizzleUserRepository(db: any, t: any): UserRepository {
  return {
    // … findById/findByEmail/create/setPasswordResetToken etc. as plain reads/writes …
    async consumeResetToken(tokenHash) {
      const now = new Date();
      // Single conditional claim: clear the token only if it still matches and
      // is unexpired; RETURNING tells us if *this* call won.
      const [claimed] = await db
        .update(t.user)
        .set({ passwordResetToken: null, passwordResetTokenExpires: null })
        .where(
          and(
            eq(t.user.passwordResetToken, tokenHash),
            or(isNull(t.user.passwordResetTokenExpires), gt(t.user.passwordResetTokenExpires, now))
          )
        )
        .returning();
      if (claimed) return mapUser(claimed);
      // Lost the race or expired → purge any expired artifact, return null.
      await db
        .update(t.user)
        .set({ passwordResetToken: null, passwordResetTokenExpires: null })
        .where(
          and(eq(t.user.passwordResetToken, tokenHash), lte(t.user.passwordResetTokenExpires, now))
        );
      return null;
    },
    async incrementTokenVersion(id) {
      // Atomic increment — never read-modify-write.
      await db
        .update(t.user)
        .set({ tokenVersion: sql`${t.user.tokenVersion} + 1` })
        .where(eq(t.user.id, id));
    }
    // … recordFailedLogin (sql increment), consumeVerificationToken (same CAS shape) …

    // The assertion is what lets this excerpt stop at 2 of the 21 methods, and
    // it switches off exactly the check the refresh-token factory above gets
    // from its return type — without the `as`: `TS2740`, 19 missing. Do not
    // carry it into your own file: `createAuthHandle` calls `user.findById` on
    // every authenticated request, so a method you never wrote fails per
    // request rather than at startup. Annotate the return type instead and let
    // the compiler enumerate what is left to write.
  } as UserRepository;
}

// invitation.markUsedIfUnused and passkey.updateCounter follow the identical
// "UPDATE … WHERE <claimable> RETURNING {id}; return rows.length === 1" shape.

Wire the per-repo factories into one Repositories object (mirroring createPrismaRepos), point describeRepositoryConformance at it, and you have a proven adapter. drizzle-orm stays a peerDependency/optional in your app — never a runtime dependency of this package.

Tests

Unit tests (Vitest, next to the modules they cover) + the E2E flow (e2e/auth.spec.ts). Run with:

cd packages/auth && bunx --bun vitest run   # unit
bun run test:e2e                            # Playwright (from the repo root)

Covered: crypto primitives (JWT, HMAC, PBKDF2, CBOR, WebAuthn parsing, TOTP/HOTP against RFC 6238/4226 vectors, AES-256-GCM secret encrypt/decrypt), CSRF, rate-limiter, session cookies, validation, notification registry/SSE/push, login handler (incl. 2FA gate), account-management and session-listing handlers, 2FA flow (setup/enable/disable/verify, backup-code single-use, pending-2FA token), E2E core flow (register → protected → logout → refresh rotation → reuse detection). Adapter conformance (adapters/conformance.ts): the atomic claim/scope guarantees run against the in-memory adapter and the Prisma adapter (via a faithful in-memory PrismaLike fake) — this is the first real test coverage for adapters/prisma.ts — plus a negative control that demonstrably fails a non-atomic adapter. The release history (v0.8.1 – v0.11.0) covers every hardening-1.0 milestone — details in the changelog.

Still open: end-to-end attestation/assertion with a real authenticator; conformance against a real database engine (the Prisma path is currently covered via the PrismaLike fake, not against a running Postgres).

Error Contract

Every handler (and the createAuthHandle gates) answers errors with one JSON shape: { error: string, code: AuthErrorCode, … }error is human-readable English prose, code the stable machine value from the append-only AUTH_ERROR_CODES set (never repurposed, only extended). The status is a property of the code: one code answers under exactly one HTTP status, everywhere it is sent, so code and status can never disagree between two handlers. The 400/401 boundary is one rule: 401 means the request was an attempt to authenticate and the credential it carried was missing or refused, and everything else about a request answers 400 — a payload the server cannot read, a request that does not match the server's state, and a wrong secret on a request that authenticates nobody. Being signed in is not itself the test. What counts as the credential it carried is worth stating, because two server-issued cookies answer differently: a refresh token is the credential — the request offers it and asks for a session in exchange — so missing_refresh_token and invalid_refresh_token are both 401, while the pending-2FA cookie is only a handle naming which challenge is open (the credential is the code in the body), so no_2fa_challenge and two_factor_challenge_expired are 400 on the same unauthenticated endpoint. One seam follows from that: a signature check cannot be told from an expiry check here, so a forged pending cookie answers 400 where a forged refresh token answers 401 — both refused, different class. Where one name would have had to straddle that boundary there are two names: invalid_code (2FA sign-in, 401) beside two_factor_setup_code_invalid (2FA enrolment, 400), and passkey_verification_failed (passkey sign-in, 401) beside passkey_registration_verification_failed (passkey enrolment, 400). Statuses outside that pair follow their own HTTP meaning and the rule does not reach them — 403 when an authenticated caller is refused the action, a failed re-auth gate (current_password_incorrect) included. Validation failures additionally carry the full field list as errors, and the first field message replaces the generic prose. Rate limits answer 429 rate_limited with a Retry-After header, while the per-user cap on concurrent SSE streams answers 429 connection_limit — a separate code because backoff never clears a connection cap, so a client that treats the two alike retries forever. <NotificationListener> reads the stream off fetch, so it sees the code: it reports the refusal through onRefused(code, status) and does not reconnect on a 4xx (anything reading the log or metrics by code tells the two apart the same way). The CSRF gate answers 403 csrf_failed; the SSE-stream refusals are JSON as of v6.17.0. The push-subscription writes distinguish push_endpoint_conflict (endpoint owned by another account — permanent) from push_subscription_limit (per-user device cap). The one deliberate exception: createMeHandler answers 401 { user: null } — that is the session-status contract of the client store, not an error report. Every refusal also carries Cache-Control: no-store, set by authError itself rather than passed at the call site, so a 404 session_not_found — a status a cache may hold on its own — cannot be replayed to anyone else; Retry-After on a 429 rides alongside it.

Localized clients map code via errorMessageFromCode(code, t, error) (exported from the package root): known code → locale bundle, unknown code or missing translation → the server prose, neither → undefined for the caller's own fallback. validation_error deliberately prefers the field-level server prose. The pre-built components do this everywhere via their shared errorTextFromBody helper. One code is client-synthesized rather than served: network_error (the request never reached the server — offline, DNS, CORS), produced by the stores and mapped to auth.errors.networkError like any other code.

The code→locale-key table is bound to the union. AUTH_ERROR_MESSAGE_KEYS (i18n/error-keys.ts) is satisfies Record<AuthErrorCode | 'network_error', …>, so a new server code that nobody keys is a compile error rather than an English sentence on a localized page; null marks the two push codes, whose copy <PushPermissionPrompt> owns (notifications.push.errorConflict / errorLimit). The English error prose is read out of the en bundle through that same table — there is one English text per code, not one for i18n consumers and another for the rest.

Passkey ceremonies answer uniformly, with one exception. The failure paths return the bare passkey_verification_failed with no prose override: none of those causes is actionable by the end user, and one of them — the sign-counter regression — is a possible-cloned-authenticator signal that must not be readable in a page. The exception is a passkey the server does not hold — deleted from another device while the browser keeps offering it: that answers passkey_credential_deleted (401, like every other refused sign-in credential), because a retry presents the same passkey again and the sentence has to name the way out (sign in another way, then set it up again). Every outcome, those two included, is told apart server-side: config.hooks.onLoginFailed(email, reason) receives a distinct reason per outcome (challenge_missing, unknown_credential, user_handle_mismatch, credential_deleted, counter_regression, user_not_found, invalid_assertion), and config.logger receives the WebAuthn detail on the paths a reason cannot carry.

Known Limitations & Security Gaps

The auth core is stable — all initial hardening items are closed; the self-service surfaces added in v5.x (account management, session listing, TOTP 2FA) are initially marked beta. The complete fix history lives in the monorepo changelog; relevant versions per area are referenced in the feature-matrix tables of the package README.

🛡️ Defense-in-Depth (planned)

  • Monitoring recommendations — for production deployments, track latency/error rate on the auth handlers; no package code needed, only a doc reference in the consumer app.

Account Enumeration & Timing

The package deliberately avoids revealing whether an account exists, via either response content or timing:

  • Login — an unknown email goes through the same PBKDF2 verify as a wrong password (against a discarded dummy hash with the configured work factor) before returning 401. There is no measurable "user does not exist" shortcut.
  • Forgot password — always responds with { success: true }; the token write + email send run decoupled from the response (fire-and-forget), so the response time doesn't reveal whether the account exists. Since a failure then no longer surfaces as an HTTP status, it is reported via config.hooks.onPasswordResetFailed (+ config.logger.error) — hook it into your own error tracker so a broken mail transport doesn't silently lock users out of recovery. Serverless/edge note: runtimes that freeze the worker after the response is sent can cut off the downstream work (and its logging/the hook) — use a queue-backed email transport there so delivery stays reliable.
  • Register — gated on possession of the invitation token, and on nothing else. Without a valid token every email returns the same 403 "invitation required", registered or not. The more precise 403/409 messages are visible only to someone holding a valid unused token, which is admin-minted and not guessable — not a usable enumeration vector, and the clear messages help the legitimately invited person. The token also names its invitee: registering with a different address is refused, so a leaked link cannot be redeemed onto an attacker-chosen account.
  • WebAuthn auth options — an unknown email returns the same 200 options response (empty allowCredentials) as a known one; discoverable/usernameless login works without any email at all.

Rate-Limiting, Lockout & Route Scope (deliberate trade-offs)

  • Lockout DoS — the login rate-limit is per-IP, the lockout is account-based. An attacker with many IPs (IPv6 rotation, proxy pool) can thereby deliberately lock out someone else's account without tripping their own IP rate-limit. This is the classic lockout trade-off (brute-force protection vs. DoS). If you don't want that, set lockout: null (then the per-IP rate-limit alone protects) — but the default deliberately leaves the lockout on, because brute-force protection is the more important risk for most apps. For visibility, consider adding your own monitoring on lockedUntil writes.
  • The login limiter brakes failures, not cost — a correct password refunds the slot its request took, so successful sign-ins from one address are not limited at all, and each of them costs a PBKDF2 run (~38 ms at the default work factor) plus a session establish. An attacker needs one account of their own for that, no one else's password; credential stuffing with a high hit rate reads the same way, every valid pair being a free session. Deliberately not capped here: a per-IP counter has never stopped a distributed cost attack, and one account hammering from one address is what an edge rate-limit (reverse proxy, WAF) is for — so a second per-IP counter that successes do not refund would be surface without protection. If the cost matters to you, brake at the edge; do not lower password.pbkdf2Iterations for it.
  • At the defaults the limiter answers before the lockout does — both thresholds are five, and the per-IP limiter runs at the top of the handler, before the account is looked up. Six wrong passwords from one address are answered 401 ×5, 429: the fifth failure did set lockedUntil, but that address never sees the 423. The same six attempts from six addresses are answered 401 ×5, 423. The two brakes therefore divide the work by who is attacking: the limiter is per IP and stops a single source, the lockout is per account and stops the distributed attempt the limiter cannot see — which is also why it is the DoS-prone one of the pair. (Both sequences are pinned against the real handler and the in-memory adapter in login.test.ts.)
  • Failed attempts decay, and that sets the sustained guess rate — an attempt stops counting once it is lockout.decayMinutes old (default 60), so typos on separate occasions never add up to a lock. The window is also the attacker's budget: every further failure re-dates the count, so waiting it out buys maxAttempts guesses per window — 5 per hour at the defaults, against the ~4.3 per hour a stuck-at-threshold counter allows (one guess per durationMinutes, each re-locking the account). Shortening it multiplies that rate — 15 minutes would allow 20 per hour — and buys a user nothing an hour of quiet does not. (All three numbers are simulated against the real handler on a 12-hour clock in login.test.ts, not derived on paper.) decayMinutes: 0 is refused when the login handler is created: a zero window resets the counter before every increment, so it would read as "no decay" and act as "no lockout" — run without a lockout via lockout: null instead. The decay needs lastFailedAt from getFailedLoginAttempts: an adapter that returns null there gets no decay, never a lost lockout. Its reset is resetFailedLoginsIfStale, guarded in the store on that same timestamp: a reset that lands after other requests have counted failures and set a lock matches nothing, so it cannot end that lock — the conformance suite fails an adapter that applies it unguarded.
  • The lockout bounds what follows a burst, not the burst — the counter is read, then written, once per request, so requests that arrive together all pass the lock check before any of them has been counted: a simultaneous burst reaches the password check in full, with or without the decay window (pinned at twelve in login.test.ts, measured unchanged at fifty). That is what the per-IP rateLimit.login is for; the lockout limits the next attempts.
  • 2FA verify rate-limit is per-IP, not per-account — the public /2fa/verify endpoint is limited per-IP like login (rateLimit.twoFactor, strict default), not per-account — consistent with the package philosophy (account-level limits are themselves DoS-prone and opt-in). A distributed-IP attack would additionally need the victim's password (to set the pending-2FA cookie); with a ±1 window (10⁶ codes) + ~5-min TTL this remains practically hopeless. A per-account limiter is a deliberate later option.
  • The UV bit is self-asserted, not attestedattestation: 'none' is hard-wired in the generated registration options, so the relying party never receives an authenticator certificate. requireUserVerification therefore verifies that the credential itself signed a UV flag, not that a genuine authenticator model performed a genuine PIN or biometric check; software or a modified authenticator can set the bit. It still moves the practical threat model — a stolen hardware key that demands its PIN is no longer a one-tap login — which is why passkey logins skip the TOTP gate only under enforced UV. Read the guarantee as "the credential claims user verification", not as a proof of it. Attestation-based authenticator allow-listing is out of v1 scope.
  • TOTP replay within the same time window — v1 relies solely on the strict verify rate-limit. A stored lastUsedStep that prevents re-redeeming the same code within its validity (±1 period) is noted as a later hardening.
  • publicRoutes replaces the defaults, and a string entry is a prefix — passing the option drops the built-in list rather than adding to it. That list is exported as DEFAULT_PUBLIC_ROUTES (read it there; it is not transcribed here), and '/api/auth/' is in it — so an override that omits it guards the app's own sign-in: an unauthenticated POST /api/auth/login gets 401 not_authenticated instead of a session. Spread the constant to extend — publicRoutes: [...DEFAULT_PUBLIC_ROUTES, '/pricing'] — and replace wholesale only for a handle scoped to routes that mount no auth endpoints of their own. A string matches with startsWith: '/api/auth/' makes all subroutes below it public, '/pricing' also publishes /pricing-admin and /pricing/internal, and '/' makes the entire app public — the obvious spelling of "my landing page is public" turns the guard off completely, and the handle warns about it at construction. One pathname alone is the object form, { path: '/', exact: true }: the landing page, and nothing under it. A list held in a variable first (const routes = [...]; createAuthHandle({ publicRoutes: routes })) needs as const or the annotation PublicRoute[] — TypeScript otherwise widens exact: true to boolean and the assignment is a type error; an inline list needs nothing. The same two forms apply to createFederatedAuthHandle. Keep the list narrow, and don't place protectable app routes below a public prefix.
  • Remote functions are guarded by event.isRemoteRequest (+ a /remote POST check), not publicRoutes. For SvelteKit Remote Functions (kit.experimental.remoteFunctions) the pathname the guard sees is client-controlled, via either of two transports: (1) the /_app/remote/… calls (query / command / JS-enhanced form) — SvelteKit overwrites event.url.pathname from the x-sveltekit-pathname header before the handle hook runs; a plain query is even a GET (payload in ?payload=), so the kernel's non-GET cross-site block doesn't catch it either; and (2) the no-JS <form action="?/remote=…"> fallback — dispatched through the page pipeline from the /remote search param, decoupled from the pathname, with event.isRemoteRequest left false. Either way a spoofed (or genuinely) public route such as /auth/login would let an unauthenticated remote call run without a session and leak every reachable row. createAuthHandle default-denies (401) both: keyed on the unspoofable event.isRemoteRequest, and — for the fallback — a POST carrying a truthy /remote action param (mirroring SvelteKit's own dispatch gate; a normal action named remote serializes to an empty ?/remote and is correctly left to the path guard). Set allowUnauthenticatedRemote: true only if you deliberately expose public remote functions — you then own their authorization (check event.locals.user inside each). Defense-in-depth: even with the guard active, prefer an explicit event.locals.user (+ per-user ownership) check inside each remote function rather than relying on the handle alone — the guard is a backstop, not a substitute for per-function authorization.
  • SvelteKit's built-in csrf.checkOrigin is a separate gate that runs before this package's check. SvelteKit's request kernel performs its own Origin-CSRF check before the handle hook runs (@sveltejs/kitsrc/runtime/server/respond.js), so csrf.exempt cannot reach it: a cross-origin, form-encoded POST — an OAuth 2.1 token endpoint, a third-party webhook — is answered 403 "Cross-site POST form submissions are forbidden" before your hook (and therefore this package's validateCsrf) ever runs. It fires on form content types only, cannot be disabled per route from a hook, admits no header-less caller through trustedOrigins, and is skipped under vite dev, never in a build (guaranteed by the package's @sveltejs/kit ^2.70.1 peer range — older Kits compiled the gate out of non-production-NODE_ENV builds, sveltejs/kit#16313), so the 403 typically first surfaces after deploy, never in local development. Which callers it stops, the build-time off-switch (kit.csrf: { trustedOrigins: ['*'] }) and the two conditions under which disabling it is safe: Machine callers. The exemption from this package's gate is csrf.exempt on createAuthHandle — never a route mounted around the hook.
  • SSE presence is process-localcreateSSEManager registers connections in this process only; there is no cross-instance seam. On multi-instance/serverless deployments, isOnline false-negatives make send() skip the live SSE event for users connected to another instance and fall back to push (delivery still happens, via the heavier channel, provided a push subscription exists), and recipients: 'online' broadcasts only reach the users connected to the instance running send(). Treat the notification system as single-instance until a shared presence backend exists — the same class of assumption as the in-memory rate-limit store.
  • In-memory adapter grows unboundedcreateInMemoryRepos doesn't clean up notifications/push subscriptions and doesn't call deleteExpired for refresh tokens automatically. user.delete is not one of these: every repository built on one createInMemoryStore() shares its tables, and the delete removes a user's dependent rows from all of them, the same end state the Prisma adapter gets from onDelete: Cascade — both are pinned by the conformance suite. This is fine for the declared dev/test scope; for long-lived processes use a persistent adapter.

Production-Readiness Checklist

Before production use outside of controlled environments:

Package setup (one-time):

  • Persistent-storage adapter for challenges, refresh tokens, and rate-limits configured (at >1 instance). Interfaces/adapters since v0.8.5 – v0.11.0.
  • CSRF double-submit decided (config.csrf = { doubleSubmit: true }, since v0.8.4). Enable it only when every cookie-authenticated mutation sends the x-csrf-token header — i.e. all mutations go through this package's client stores/components or csrfFetch. SvelteKit Remote Functions (command / form) and native no-JS form posts send no such header (the remote transport is Kit-internal — you cannot attach one), so with those in play doubleSubmit: true would 403 every such mutation: keep it false and rely on Layer 1 — the Origin gate holds for a same-origin deployment on the default SameSite=Lax cookies, provided nothing in front of the app rewrites or injects the Origin header; remote-function mutations additionally sit behind Kit's own non-configurable strict same-origin remote gate. Layer 2's real added value is exactly that residue: an Origin-normalizing proxy/gateway combined with cookieSameSite: 'none' lets a cross-site request satisfy the Origin check, while the token cookie stays unreadable to the attacker — if that is your deployment, don't drop Layer 2; restructure the mutating surface so it can send the header. Optionally also useHostPrefix: true (HTTPS-only) against subdomain cookie injection — then also set useHostPrefix: true in the csrf config of the client stores/components.
  • Refresh-token rotation enabled (config.refreshToken = { … }, since v0.11.0) — non-breaking, recommended in production.

Deployment:

  • HTTPS enforced (cookies are already secure: true; HSTS is then set automatically by createAuthHandle).
  • CSP adapted to the app (config.securityHeaders.csp) — the default frame-ancestors 'none' blocks only framing; a complete policy is app-specific.
  • Monitoring for auth-handler latency and error rate active.
  • Incident runbook for a compromised JWT secret prepared (keyId + previousSecrets allows uninterrupted rotation).
  • Incident runbook for twoFactor.encryptionKey prepared — it has no overlap mechanism, so a rotation locks out every TOTP user and blocks their re-enrolment; a backup code — or a passkey, which is not TOTP-gated — is the way back in (see the key-rotation runbook). Keep the key backed up separately from the database, and alert on the 2fa-enable / 2fa-verify decryption errors in config.logger — they never reach handleError.
  • Machine callers declared — every cron, OAuth-token or API-key route that posts without an Origin header is listed in csrf: { exempt } on createAuthHandle and authenticates each request itself (it sees locals.user === null by design). For the form-encoded ones (an OAuth 2.1 token endpoint, a webhook): kit.csrf: { trustedOrigins: ['*'] } set in svelte.config.js — the build-time off-switch for SvelteKit's kernel gate, which a specific origin list cannot open for a header-less caller — and confirmed every cookie-authenticated mutating route still flows through the auth handle (Machine callers). The kernel CSRF check never runs under vite dev, so this won't show up before a deployed build.
  • If you run createFederatedAuthHandle (SSO consumer): the kernel CSRF gate stays on — the trustedOrigins: ['*'] resolution above is off-limits there (no validateCsrf backstop behind the federated handle) unless you gate cookie-authenticated mutations yourself via the exported validateCsrf in your own hook (see Federated Identity).
  • If you use SvelteKit Remote Functions (kit.experimental.remoteFunctions): confirmed each remote function checks event.locals.user (and per-user ownership) itself — the handle default-denies unauthenticated remote requests, but per-function checks are defense-in-depth. Only set allowUnauthenticatedRemote: true for deliberately public remote functions (see Known Limitations).

Your own tests:

  • E2E passkey flow against a real authenticator (registration + authentication) — the shipped tests cover the session core flow, the WebAuthn crypto at the parser level, and the auth-ceremony plumbing at the handler level: the challenge is bound to an opaque per-ceremony handle (HttpOnly cookie between authentication-options and authentication-verify), enabling both discoverable/usernameless and email-first login. An end-to-end signature check against real hardware remains on the consumer side. Your own client: the cookie from the authentication-options response must be sent along with the authentication-verify request — fetch/csrfFetch do that same-origin automatically; the shipped LoginPage is already correct.

Consumer Migration

Lifting an existing SvelteKit app onto @urbicon-ui/auth roughly follows the staged setup progression from README → Getting Started (Quickstart → Production → Advanced). The detailed migration order and app-specific steps (e.g. schema diffs, role mapping, replacing existing stores) belong in the respective consumer repo — not in this package.