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 Crypto —
globalThis.cryptowithcrypto.subtle+getRandomValuesbacks 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) useBuffer. Password hashing is on the login/register path, so in practice this makes the package Node ≥ 20 or Bun (both shipBufferglobally); 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 asPartialAuthLocale: any subset, deep-merged over the active built-in bundle bymergeAuthLocale(both root-exported). Overriding one string never blanks the rest; a hand-rolled bundle missing newer keys resolves them from the base.AuthLocaleitself 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-meton RegisterPage requirement rows,data-unreadon 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'sdefaultsand before this instance'sslotClasses. Reach for it when the same override would otherwise be repeated at every usage site.NotificationListeneris the one component without any of the three — it renders no markup.- Snippet overrides —
header/footer/linkson all five pages,item(NotificationCenter),qr(TwoFactorManager) - Semantic design tokens —
text-text-primary,bg-surface-quiet, etc. The tokens come from the consumer's blocks stylesheet; this package's own@urbicon-ui/auth/style/index.cssadds only the Tailwind@sourcefor 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_codekeeps the sign-in step (401, unchanged). Enrolment answers the newtwo_factor_setup_code_invalid(400, unchanged) when the code typed against the staged secret does not match. - Passkeys.
passkey_verification_failedkeeps the sign-in ceremony, and its status changes from400to401— 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 newpasskey_registration_verification_failed(400).passkey_credential_deletedalso moves from400to401: the browser did present a credential and the server refused it, and "unknown" rather than "invalid" is the reason — which is what thecodecarries. Every way a passkey sign-in can be refused now answers401.
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 tokenTtl — emailVerification (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 exportedsanitizeRedirect(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 rotation —
jwt.keyId+jwt.previousSecretsroll the signing secret without invalidating live sessions. - Passkeys (WebAuthn) — mount the
createPasskeyHandlers(deps, webauthn)route group (the four ceremony groups pluslist/itemfor<PasskeyManager>'s list, rename and remove —itemcarries bothDELETEandPATCHon…/[credentialId]; requiresdeps.repos.passkey, throws at wiring time without it) with awebauthn: WebAuthnConfig(persistentchallengeStoreat >1 instance; user verification is enforced by default —requireUserVerification: falseopts out for authenticators that cannot do UV, and because passkey logins skip the TOTP gate, that opt-out alongsideconfig.twoFactormakes a passkey login single-factor for a TOTP-enrolled user;createPasskeyHandlerswarns 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.urlis untrusted at navigation time — validate/allow-list it beforegoto(). The list/mark-read/read-all/delete routes thatcreateNotificationStorecalls are served bycreateNotificationsHandlers(service)— mount itslist/read/readAll/itemgroups under the store'sapiPath(default/api/notifications); every method derivesuserIdfromlocals.userand goes through the ownership-scoped service methods (IDOR-safe by construction). Endpoint takeover is key-gated:pushSubscription.createupserts 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 with409and 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 onglobalThisto survive hot reloads, module-scoperegister()calls re-run against the surviving instance and throw; cache the registrations together with the registry (or guard withregistry.get(key)). A type declared withrecipients: 'admins'requires aresolveAdminRecipientsresolver oncreateNotificationService(e.g.() => repo.findAdminUserIds()) — the package has no role model of its own, so without itsend()throws rather than silently delivering admin alerts to nobody. Push delivery failures are swallowed so one bad subscription can't break a send; passonPushResultto 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: nullopts out),createPreferencesHandlerrequires the registry and rejects unregisteredtypeKeys (bounding per-user preference rows to the registered types), and stored subscriptions are capped per user (maxSubscriptionsPerUser, default 10,409beyond — re-subscribes of a known endpoint always pass). - Invitations (admin) —
createInvitationHandlers(deps, { authorize, roles })is the server half of<InvitationManager>: mountPOST+GETon/api/invitationsandDELETEon/api/invitations/[id].authorizeis 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.rolesallow-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). The201returnsinviteUrl—${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 inGET: the server keeps only the hash, so an admin who loses the link revokes the invitation and mints a new one. When the client setssendEmail, the handler also mails that link — best-effort: a mail failure still returns201withemailSent: false(logged), since the invitation row is the durable effect. Override the mail viainviteEmail. - 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
autoVerifyInvitedis honoured only for invitations with anemailedAt, and a copy-link signup gets the ordinary verification mail regardless of the setting. PasscreateRegisterHandler(deps, { autoVerifyInvited: true })to create the account withemailVerified: trueand 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, andemailVerifiednever flips. Defaults tofalse(backwards-compatible: token + mail issued as before, for consumers that genuinely gate onemailVerified). The flag is an option on the register handler's second argument, not something threaded throughcreateAuthHandle— 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.userviaconfig.hooks.transformUser(user, event). It runs in the handle hook on every authenticated request with the sanitizedAuthUser(never the password hash) and the request event; its return value becomeslocals.user(type it through yourApp.Locals).AuthUseris intentionally fixed and the loaded row carries no custom columns, so load extras here keyed byuser.idrather than adding a secondhandlethat 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 onlocals.user— keep secrets out of it iflocals.useris serialized to the client. Throw semantics acrossconfig.hooks:transformUserandonBeforeAccountDeleteare the only two gates, where a throw aborts; every other hook only reports, so a throw is caught and sent toconfig.logger.errorand 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-passwordbumpstokenVersion+ 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, unlikereset-passwordwhich signs out everywhere);change-emailverifies the new address (notice to the old one) and is account-enumeration safe (token+mail decoupled, alwayssuccess: true, collision = no-op);delete-accountis a hard delete (GDPR erasure) that fireshooks.onBeforeAccountDeletewith 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}, hooksonEmailChangeRequested/onEmailChangeFailed/onEmailChanged/onBeforeAccountDelete(onEmailChangeFailedsurfaces the decoupled token/mail failure, likeonPasswordResetFailed). - Active-session listing — let a user see and revoke their sessions. Requires
config.refreshTokenrotation — a "session" is a refresh-token family, so without rotation there is nothing server-side to list (the endpoint returnsavailable: false). One route group —createSessionsHandlers(deps)withlist(GET),revoke(POST) andrevokeOthers(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 acreatedAt-based "last active"; the IP is listed only when the consumer opts in viaconfig.sessions.storeIp(GDPR, default off). Revokes are ownership-scoped —revokeFamilyForUserreturns 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 flaggedcurrent(resolved from the request's refresh cookie). Login/register/passkey-verify tag their sessions with the device metadata viaresolveSessionMeta, carried across rotation. - Two-factor authentication (TOTP) — an opt-in authenticator-app second factor on top of the password. Set
config.twoFactor(encryptionKeyrequired — 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 providerepos.backupCode(both shipped adapters include it). One route group —createTwoFactorHandlers(deps):setup/enable/disable(authenticated, mount under/api/auth/account/2fa/*) plusverify(the public second login step, mount under/api/auth/2fa/verify); theloginhandler gates automatically onuser.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) bysetup; the login gate keys ontotpEnabledalone so a missingconfig.twoFactorcan never become a bypass (fail-closed) — it issues a signed, short-lived pending-2FA token (cookieurbicon_2fa,__Host--prefixed unless acookieSecure: falseon the session, CSRF or refresh cookie declares the deployment non-HTTPS) instead of a session, leaving no session/refresh cookie and deferringonLoginSuccessto verify; verify accepts a TOTP or a single-use SHA-256 backup code, is strictly rate-limited (createAuthDepsinjects a default), and consumes the pending cookie only on success (a wrong code keeps it for retry within the TTL);disableis 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.requireUserVerificationdefaults totrue, so an accepted assertion carries possession and an asserted PIN/biometric (asserted, not attested — see Known Limitations). Set it tofalseand a passkey is possession alone, which makes a passkey login a single-factor login for exactly the users who enrolled a second one (createPasskeyHandlerswarns 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.algorithmdefaults 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 withcreateFederatedAuthHandleand 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 types — application/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 disable → setup → enable
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.
- Do not rotate on a schedule. Unlike
jwt.previousSecrets, this key has no overlap mechanism — one key is in force at a time. - 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.
- After the rotation, drive the affected users through
disable→setup→enable(the<TwoFactorManager>flow) so their secret is re-encrypted under the new key and a fresh backup-code set is issued. - 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. - 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'sroleandtokenVersionclaims are IdP-application-internal and are structurally withheld from consumers (FederatedIdentitycarries onlysubject,issuedAt,expiresAt— type and runtime). Forwarding the IdP's role would be exactly the identity/authorization confusion this design forbids: an "admin" atauth.example.comis not an admin of your billing app.
Architecture (prose diagram)
- IdP (
auth.example.com): switches its session JWT tojwt.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 viacreateJWKSHandler— served withCache-Control: public, max-age=300. Login, logout, registration, refresh rotation, 2FA: all stay exclusively at the IdP. Withjwt.cookieDomain: '.example.com'the session cookie is shared with every*.example.comsibling (the refresh and CSRF cookies deliberately stay host-scoped — consumers verify, they never rotate). - Consumer (
app.example.com): mountscreateFederatedAuthHandleas itshooks.server.tshandle. Per request it verifies the shared cookie's ES256 signature against the IdP's JWKS (fetched lazily, cached ~5 min, cooldown-limited refresh on unknownkid— no fetch storms; fetch failures fail closed with one loud log line, never a 500), then callsresolveUser(identity, event). The result islocals.user(same locals contract ascreateAuthHandle);nulldenies. Route guard:publicRoutesentries pass (a string prefix or{ path, exact: true }, read exactly as on the IdP handle),/api/gets a JSON 401, pages 302 tologinUrl(typically the IdP login; used verbatim — noredirectTo, since the IdP'ssanitizeRedirectadmits 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 settrustedOrigins: ['*']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 mandatorykid) 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
purposeclaim, and every verifier requires it in the primitive — not in each caller's claim-shape check. Session tokens (HS256 and ES256 alike) are stampedpurpose: 'session'(exported asSESSION_TOKEN_PURPOSE);verifySessionTokenand 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 mandatorypurposeparameter, stamped at mint and matched verbatim at verify (purposeis a reserved claim; a missing/empty purpose argument throws — API misuse, not a bad token). Result: two token kinds signed with the samejwt.secretcan 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,verifySignedTokenand the consumer handle reject input longer thanMAX_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
- 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. - Config:
jwt: { algorithm: 'ES256', signingKey: privateKey, secret, cookieDomain: '.example.com' }.jwt.secretstays required — package-internal short-lived tokens (pending-2FA handle etc.) deliberately remain HMAC. Misconfiguration (ES256 without a usable key, malformedpreviousPublicKeys) throws at wiring time increateAuthDeps/createAuthHandle. - Mount the JWKS route, e.g.
src/routes/.well-known/jwks.json/+server.ts:
Exempt the route from the IdP's own guard if it falls outside your public prefixes —import { createJWKSHandler } from '@urbicon-ui/auth/server'; export const { GET } = createJWKSHandler({ jwt: config.jwt });publicRoutesreplaces the defaults rather than adding to them, so that readspublicRoutes: [...DEFAULT_PUBLIC_ROUTES, '/.well-known/']. Only public JWK members can reach the response (allow-list projection; adin 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)
- Generate the next pair:
const next = await generateES256KeyPair(). - On the IdP, deploy in ONE step:
signingKey: next.privateKey, and move the public JWK of the retiring key intojwt.previousPublicKeys(it already carries itskid; for a hand-built entry derive it withcomputeJwkThumbprint). The JWKS now serves both keys; old sessions keep verifying, new tokens carry the newkid. - Consumers converge automatically: an unknown
kidtriggers a (cooldown-limited) JWKS refresh, so the new key propagates within about a minute — no consumer deploy. - After the old sessions have expired (
jwt.expiresIn, default 7d), remove the retired entry frompreviousPublicKeys. 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
tokenVersionbumps ("log out everywhere"), so an IdP-revoked session stays verifiable at consumers untilexp. Bound the window with short-lived IdP access tokens (refreshTokenrotation keeps UX intact) and/or the consumer-sidemaxTokenAgeoption; 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
resolveUserinstead). - 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 untilexp, and (b) set a.example.comsession 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 onecookieDomain, 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,createFederatedAuthHandledoes 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. ThetrustedOrigins: ['*']resolution from Known Limitations is off-limits on a federated consumer unless you build the backstop yourself: there is novalidateCsrfbehind 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 exportedvalidateCsrf(event.request, event.url)in its own hook (sequenced beforecreateFederatedAuthHandle), exempting its machine routes there. What that hook cannot give is the cookieless handlingcreateAuthHandlegivescsrf.exempt:createFederatedAuthHandlehydrateslocals.useron 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 inpublicRoutes, 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 email←pendingEmail 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:
- An id your store cannot represent behaves exactly like an id that is absent — return
null, returnfalse, 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 (idSafeClientinadapters/prisma.ts). - 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 theP2025thatupdate/deleteraise.
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 viaconfig.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 precise403/409messages 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
200options response (emptyallowCredentials) 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 onlockedUntilwrites. - 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.pbkdf2Iterationsfor 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 setlockedUntil, but that address never sees the423. The same six attempts from six addresses are answered401 ×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 inlogin.test.ts.) - Failed attempts decay, and that sets the sustained guess rate — an attempt stops counting once it is
lockout.decayMinutesold (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 buysmaxAttemptsguesses per window — 5 per hour at the defaults, against the ~4.3 per hour a stuck-at-threshold counter allows (one guess perdurationMinutes, 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 inlogin.test.ts, not derived on paper.)decayMinutes: 0is 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 vialockout: nullinstead. The decay needslastFailedAtfromgetFailedLoginAttempts: an adapter that returnsnullthere gets no decay, never a lost lockout. Its reset isresetFailedLoginsIfStale, 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-IPrateLimit.loginis for; the lockout limits the next attempts. - 2FA verify rate-limit is per-IP, not per-account — the public
/2fa/verifyendpoint 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 attested —
attestation: 'none'is hard-wired in the generated registration options, so the relying party never receives an authenticator certificate.requireUserVerificationtherefore 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
lastUsedStepthat prevents re-redeeming the same code within its validity (±1 period) is noted as a later hardening. publicRoutesreplaces 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 asDEFAULT_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 unauthenticatedPOST /api/auth/logingets401 not_authenticatedinstead 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 withstartsWith:'/api/auth/'makes all subroutes below it public,'/pricing'also publishes/pricing-adminand/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 })) needsas constor the annotationPublicRoute[]— TypeScript otherwise widensexact: truetobooleanand the assignment is a type error; an inline list needs nothing. The same two forms apply tocreateFederatedAuthHandle. Keep the list narrow, and don't place protectable app routes below a public prefix.- Remote functions are guarded by
event.isRemoteRequest(+ a/remotePOST check), notpublicRoutes. 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-enhancedform) — SvelteKit overwritesevent.url.pathnamefrom thex-sveltekit-pathnameheader before thehandlehook runs; a plainqueryis even a GET (payload in?payload=), so the kernel's non-GETcross-site block doesn't catch it either; and (2) the no-JS<form action="?/remote=…">fallback — dispatched through the page pipeline from the/remotesearch param, decoupled from the pathname, withevent.isRemoteRequestleftfalse. Either way a spoofed (or genuinely) public route such as/auth/loginwould let an unauthenticated remote call run without a session and leak every reachable row.createAuthHandledefault-denies (401) both: keyed on the unspoofableevent.isRemoteRequest, and — for the fallback — aPOSTcarrying a truthy/remoteaction param (mirroring SvelteKit's own dispatch gate; a normal action namedremoteserializes to an empty?/remoteand is correctly left to the path guard). SetallowUnauthenticatedRemote: trueonly if you deliberately expose public remote functions — you then own their authorization (checkevent.locals.userinside each). Defense-in-depth: even with the guard active, prefer an explicitevent.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.checkOriginis a separate gate that runs before this package's check. SvelteKit's request kernel performs its own Origin-CSRF check before thehandlehook runs (@sveltejs/kit→src/runtime/server/respond.js), socsrf.exemptcannot reach it: a cross-origin, form-encodedPOST— an OAuth 2.1 token endpoint, a third-party webhook — is answered403 "Cross-site POST form submissions are forbidden"before your hook (and therefore this package'svalidateCsrf) ever runs. It fires on form content types only, cannot be disabled per route from a hook, admits no header-less caller throughtrustedOrigins, and is skipped undervite dev, never in a build (guaranteed by the package's@sveltejs/kit ^2.70.1peer range — older Kits compiled the gate out of non-production-NODE_ENVbuilds, 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 iscsrf.exemptoncreateAuthHandle— never a route mounted around the hook. - SSE presence is process-local —
createSSEManagerregisters connections in this process only; there is no cross-instance seam. On multi-instance/serverless deployments,isOnlinefalse-negatives makesend()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), andrecipients: 'online'broadcasts only reach the users connected to the instance runningsend(). 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 unbounded —
createInMemoryReposdoesn't clean up notifications/push subscriptions and doesn't calldeleteExpiredfor refresh tokens automatically.user.deleteis not one of these: every repository built on onecreateInMemoryStore()shares its tables, and the delete removes a user's dependent rows from all of them, the same end state the Prisma adapter gets fromonDelete: 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 thex-csrf-tokenheader — i.e. all mutations go through this package's client stores/components orcsrfFetch. 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 playdoubleSubmit: truewould 403 every such mutation: keep itfalseand rely on Layer 1 — the Origin gate holds for a same-origin deployment on the defaultSameSite=Laxcookies, provided nothing in front of the app rewrites or injects theOriginheader; 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 withcookieSameSite: '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 alsouseHostPrefix: true(HTTPS-only) against subdomain cookie injection — then also setuseHostPrefix: truein thecsrfconfig 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 bycreateAuthHandle). - CSP adapted to the app (
config.securityHeaders.csp) — the defaultframe-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+previousSecretsallows uninterrupted rotation). - Incident runbook for
twoFactor.encryptionKeyprepared — 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 the2fa-enable/2fa-verifydecryption errors inconfig.logger— they never reachhandleError. - Machine callers declared — every cron, OAuth-token or API-key route that posts without an
Originheader is listed incsrf: { exempt }oncreateAuthHandleand authenticates each request itself (it seeslocals.user === nullby design). For the form-encoded ones (an OAuth 2.1 token endpoint, a webhook):kit.csrf: { trustedOrigins: ['*'] }set insvelte.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 undervite dev, so this won't show up before a deployed build. - If you run
createFederatedAuthHandle(SSO consumer): the kernel CSRF gate stays on — thetrustedOrigins: ['*']resolution above is off-limits there (novalidateCsrfbackstop behind the federated handle) unless you gate cookie-authenticated mutations yourself via the exportedvalidateCsrfin your own hook (see Federated Identity). - If you use SvelteKit Remote Functions (
kit.experimental.remoteFunctions): confirmed each remote function checksevent.locals.user(and per-user ownership) itself — the handle default-denies unauthenticated remote requests, but per-function checks are defense-in-depth. Only setallowUnauthenticatedRemote: truefor 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-optionsandauthentication-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 theauthentication-optionsresponse must be sent along with theauthentication-verifyrequest —fetch/csrfFetchdo that same-origin automatically; the shippedLoginPageis 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.