@urbicon-ui/auth
v6.3.3
Published
Authentication for SvelteKit — JWT sessions, passkeys/WebAuthn, notifications and email with zero runtime dependencies
Downloads
914
Maintainers
Readme
@urbicon-ui/auth
Zero-runtime-dependency authentication, user-management, and notification system for SvelteKit. Part of the vertical Urbicon UI platform.
All crypto is implemented with the Web Crypto API — no bcrypt, no jsonwebtoken, no Web-Push vendor SDK. Server-side handler factories, a Handle-Hook for SvelteKit, an adapter interface (Prisma adapter included), and 14 blocks-based UI components covering login, registration, password reset, email verification, invitation management, passkeys, account management, active sessions, two-factor (TOTP), and notifications.
Maturity: core stable (hardened for production SvelteKit deployments, including persistent-store adapters for challenges / rate-limits / refresh tokens); the newest self-service surfaces — account management, session listing, TOTP 2FA — are
beta. See docs/AUTH.md — Known Limitations for the residual gap list.
New here? Jump to the Quickstart — a copy-paste setup that runs in five minutes with no database or mail server. Then graduate to Production and Advanced.
Installation
This package ships inside the Urbicon UI monorepo. Install from repo root:
bun installPeer dependencies: svelte (^5), @sveltejs/kit, @urbicon-ui/blocks, @urbicon-ui/i18n.
Runtime dependencies: none.
Feature Matrix
| Area | Capability | Standards |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| Sessions | JWT (HMAC-SHA256), httpOnly/secure/sameSite=lax cookie, 7-day TTL (shortens to 15 min when refresh-rotation is on), tokenVersion invalidation, opt-in key rotation via kid + previousSecrets (since v0.10.0) | — |
| Refresh tokens | Opt-in rotation via config.refreshToken + repos.refreshToken; 15-min access / 30-day rotating refresh, token families, SHA-256-hashed storage, reuse-detection (replaying a rotated token revokes the whole family), transparent rotation in createAuthHandle and explicit createRefreshHandler (since v0.11.0) | — |
| Passwords | PBKDF2 (600k iter, SHA-256), legacy bcrypt auto-upgraded via dual-verify | — |
| Passkeys | Registration + authentication, counter check for cloning, ES256 + RS256, pluggable challenge store (in-memory default, optional Redis/Prisma/etc. via ChallengeStore), optional User-Verification (UV) enforcement via requireUserVerification (since v0.10.0) | WebAuthn Level 2, FIDO2 |
| Two-factor (2FA) | Opt-in TOTP second factor via config.twoFactor + repos.backupCode: zero-dep RFC-6238/4226 codes, AES-256-GCM-encrypted secret at rest, signed short-lived pending-2FA cookie between password and code, single-use SHA-256 backup codes, strict per-step rate-limit. Login two-step + TwoFactorManager UI. Passkey logins are not gated. | RFC 6238, 4226, 4648 |
| Web Push | ECDH P-256 + HKDF + AES-128-GCM, VAPID JWT signing, opt-in per-endpoint rate-limit (since v0.10.0) | RFC 8291, 8292, 8188 |
| Email | Transport interface, Lettermint adapter + console logger (dev) | — |
| CSRF | Origin-header validation (always on for requests routed through createAuthHandle) + opt-in Double-Submit-Cookie (since v0.8.4), optional __Host- cookie prefix (csrf.useHostPrefix) against subdomain injection | — |
| Rate-limit | Pluggable store (in-memory default, optional Redis/Prisma/etc. adapter via RateLimitStore), configurable window/max | — |
| Security headers | Always on: nosniff, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy. Configurable via config.securityHeaders: HSTS (default max-age=63072000; includeSubDomains, only when jwt.cookieSecure !== false) + CSP hook (default frame-ancestors 'none') | — |
Package Exports
| Export | Condition | Contents |
| ---------------------------------------------- | -------------- | ----------------------------------------------------------- |
| @urbicon-ui/auth | Universal | Client stores, components, types |
| @urbicon-ui/auth/server | Server | Handlers, auth core, adapters, i18n |
| @urbicon-ui/auth/server/adapters/prisma | Server | Prisma adapter factory (createPrismaRepos) |
| @urbicon-ui/auth/server/adapters/in-memory | Server | In-memory adapter (createInMemoryRepos) — dev/test |
| @urbicon-ui/auth/server/adapters/conformance | Server (tests) | Adapter conformance suite (describeRepositoryConformance) |
| @urbicon-ui/auth/server/email/lettermint | Server | Lettermint email transport |
| @urbicon-ui/auth/server/email/console | Server | Console email transport (dev only) |
| @urbicon-ui/auth/sw | Service worker | Push + notification-click handlers |
| @urbicon-ui/auth/i18n/en | Universal | English locale bundle |
| @urbicon-ui/auth/i18n/de | Universal | German locale bundle |
UI Components
All use @urbicon-ui/blocks primitives and honour unstyled + slotClasses + snippet overrides.
| Component | Purpose |
| ---------------------- | ----------------------------------------------- |
| LoginPage | Login form with optional passkey entry point |
| RegisterPage | Registration form (optionally invitation-gated) |
| ForgotPasswordPage | Password-reset request |
| ResetPasswordPage | Password-reset with confirmation |
| VerifyEmailPage | Auto-verifying email confirmation |
| InvitationManager | Admin invitation list + create/revoke |
| 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 |
Getting Started
Three stages, each building on the last: a five-minute dev quickstart, a production
hardening pass, then the advanced surface. createAuthHandle is mandatory in every
stage — it hydrates the session, guards routes, applies the response security headers,
and enforces CSRF. Skip it and those protections are simply off.
Stage 1 — Quickstart (dev, 5 minutes)
Runs with no database and no mail server: the in-memory adapter keeps everything in heap Maps, the console transport prints emails to your terminal. State is wiped on every restart — dev only, never production.
1. Dependencies — src/lib/server/auth-setup.ts:
import { createAuthDeps } from '@urbicon-ui/auth/server';
import { createInMemoryRepos } from '@urbicon-ui/auth/server/adapters/in-memory';
import { createConsoleEmailTransport } from '@urbicon-ui/auth/server/email/console';
export const authDeps = createAuthDeps({
config: {
jwt: { secret: 'dev-secret-change-me', cookieSecure: false }, // cookieSecure:false = http dev
appUrl: 'http://localhost:5173', // trusted base for email links — required
routes: { afterLogin: '/', loginPage: '/auth/login' }
},
repos: createInMemoryRepos(),
email: createConsoleEmailTransport() // dev only — prints emails to the terminal
});createAuthDeps fills in secure brute-force defaults automatically (login rate-limit
5 / 15 min + lockout 5 / 15 min) — even the quickstart isn't an open door. The login
rate-limit default is injected even if you configure only other endpoints (so
rateLimit: { register } never silently leaves login unprotected); the lockout default
applies only when you set neither rateLimit nor lockout. Opt out of either explicitly
with null. cookieSecure: false marks this as a dev config, which suppresses the
production hardening warnings (and HSTS) you'd otherwise see.
2. Hook — src/hooks.server.ts:
import { createAuthHandle } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const handle = createAuthHandle({ config: authDeps.config, repos: authDeps.repos });Origin/CSRF is enforced only for requests that flow through the handle. If you later route a cross-origin, form-encoded endpoint (an OAuth 2.1 token endpoint, a webhook) around
createAuthHandle, SvelteKit's own built-incsrf.checkOrigin— which runs in the request kernel before any hook, in production builds only — will403it. Resolution and safety preconditions: docs/AUTH.md → Known Limitations.
3. API route stubs — one file per handler, e.g. src/routes/api/auth/login/+server.ts:
import { createLoginHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { POST } = createLoginHandler(authDeps);Repeat for logout, register, forgot-password, reset-password, verify-email, me.
4. UI page — src/routes/auth/login/+page.svelte:
<script>
import { LoginPage } from '@urbicon-ui/auth';
import { en } from '@urbicon-ui/auth/i18n/en';
import { goto } from '$app/navigation';
</script>
<LoginPage t={en} onSuccess={() => goto('/')} />You now have a working email/password flow. Registration is invitation-gated, so seed one
invitation first —
await authDeps.repos.invitation.create({ email: '[email protected]', role: 'USER', invitedById: 'seed' }) —
then register, watch the verification email print to your terminal, and log in.
Stage 2 — Production
Swap the two dev pieces — in-memory → Prisma, console → a real transport — and turn on the hardening layers. Everything here is opt-in and additive: the Stage 1 hook and route stubs are unchanged; you're only growing the config.
// src/lib/server/auth-setup.ts
import { createAuthDeps } from '@urbicon-ui/auth/server';
import { createPrismaRepos } from '@urbicon-ui/auth/server/adapters/prisma';
import { createLettermintTransport } from '@urbicon-ui/auth/server/email/lettermint';
import { env } from '$env/dynamic/private';
import { prisma } from './prisma';
type AppRole = 'ADMIN' | 'USER';
export const authDeps = createAuthDeps<AppRole>({
config: {
jwt: { secret: env.JWT_SECRET }, // cookieSecure defaults true → HTTPS + auto HSTS
appUrl: env.PUBLIC_APP_URL, // trusted base for email links — never request.url
email: { from: 'Acme <[email protected]>' }, // default sender for all auth emails
csrf: { doubleSubmit: true }, // token layer on top of the handle's always-on Origin check
refreshToken: { accessTokenTtl: '15m', refreshTokenTtl: '30d' }, // rotating refresh
rateLimit: {
login: { windowMs: 900_000, max: 5 },
passwordReset: { windowMs: 3_600_000, max: 3 },
resetPassword: { windowMs: 3_600_000, max: 5 }
},
lockout: { maxAttempts: 5, durationMinutes: 15 },
routes: { afterLogin: '/', loginPage: '/auth/login' }
},
repos: createPrismaRepos<AppRole>(prisma), // pulls in the refreshToken adapter
email: createLettermintTransport({ token: env.LETTERMINT_TOKEN }) // sends via the Lettermint v2 API
});Add a refresh route stub (createRefreshHandler) once rotation is on. With the
RefreshToken model in your Prisma schema (see prisma/auth-schema.prisma), the handle
hook rotates the refresh cookie whenever the access token expires and revokes the old one;
replaying a revoked token triggers family-wide revocation — a stolen-token scenario
logs every session in that family out.
Production-readiness checklist
Mirrors docs/AUTH.md → Produktionsreife-Checkliste:
- [ ] HTTPS enforced — cookies are already
secure: true; HSTS is emitted automatically oncejwt.cookieSecure !== false. - [ ] CSRF Double-Submit on (
csrf.doubleSubmit: true); optionallyuseHostPrefix: true(HTTPS-only) — then setuseHostPrefix: trueon the client stores/components too. - [ ] Refresh-token rotation on (
refreshToken: {}+repos.refreshToken) — non-breaking, recommended. - [ ] Rate-limit + lockout active (defaulted by
createAuthDeps; tune per handler). Use a persistentRateLimitStorewhen running >1 instance. - [ ] Persistent stores for challenges / refresh tokens / rate limits at >1 instance.
- [ ] CSP tuned to your app (
securityHeaders.csp) — the default only blocks framing. - [ ]
appUrlset to the real public origin;JWT_SECRETfrom a secret store, with akeyId+previousSecretsrotation runbook ready. - [ ] Monitoring on auth-handler latency + error rate; wire
hooks.onPasswordResetFailedto your error tracker so a broken mail transport doesn't silently lock users out of recovery. - [ ] Cross-origin form-encoded endpoint outside the handle? (OAuth 2.1 token, webhook) — set
kit.csrf: { trustedOrigins: ['*'] }insvelte.config.js(SvelteKit's kernel CSRF check, production-only, otherwise403s it before the hook) and confirm every cookie-auth mutating route still flows throughcreateAuthHandle. See docs/AUTH.md → Known Limitations.
CSRF on the client
With csrf.doubleSubmit enabled, createAuthHandle is what sets the urbicon_csrf
cookie and rejects mutating requests without a matching x-csrf-token header — it is not
optional for CSRF. The bundled stores and components already echo the header. For your own
client fetches use the exported csrfFetch:
<script>
import { csrfFetch } from '@urbicon-ui/auth';
async function submit() {
const res = await csrfFetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
/* order fields */
})
});
}
</script>Or use withCsrfHeader(init) / readCsrfToken() directly in a custom fetch wrapper.
Cookie/header names are configurable via config.csrf.cookieName / config.csrf.headerName
(pass the same names to the client csrf config).
Stage 3 — Advanced
- Custom persistence adapter — anything beyond Prisma/in-memory (Drizzle, Kysely, raw SQL): follow the Adapter Authoring Guide and validate it against the exported conformance suite so its atomic claims are provably race-safe.
- JWT key rotation — set
jwt.keyId+jwt.previousSecretsto roll the signing secret without logging everyone out; old sessions verify against the previous secret until they expire. - Passkeys (WebAuthn) — wire
createPasskey*Handlers with awebauthn: WebAuthnConfig(pass a persistentchallengeStoreat >1 instance; setrequireUserVerification: trueto enforce UV), and drop in<PasskeyManager>+ the passkey entry point on<LoginPage mode="both">. - Notifications & Web Push — register domain events server-side and listen client-side:
// Server: register domain events
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 — see note
recipients: (data) => [data.userId]
});<!-- Client: listen + display -->
<script>
import {
NotificationListener,
NotificationCenter,
createNotificationStore
} from '@urbicon-ui/auth';
const store = createNotificationStore();
</script>
<NotificationListener onNotification={(n) => store.add(n)} />
<NotificationCenter
t={en}
notifications={store.notifications}
onMarkAsRead={(id) => store.markAsRead(id)}
/>- Account management (self-service) — let a signed-in user manage their own account. Mount the four handlers under
/api/auth/account/*and drop in<AccountSettings>:
// src/routes/api/auth/account/change-password/+server.ts
import { createChangePasswordHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { POST } = createChangePasswordHandler(authDeps);
// …and change-email, profile, delete the same way; plus a verify-email-change
// route (createVerifyEmailChangeHandler) behind the link sent to the new address.<script>
import { AccountSettings } from '@urbicon-ui/auth';
let { data } = $props(); // data.user from your load fn (locals.user)
</script>
<AccountSettings user={data.user} onDeleted={() => goto('/')} />All four mutations are re-auth gated (current password). change-password keeps the
current device signed in while logging out every other session; change-email verifies the
new address and is account-enumeration safe (always "check your inbox"); delete-account
hard-deletes and fires hooks.onAccountDeleted before erasing so you can archive.
- Active-session listing — show the user their sessions and let them sign devices out. Requires
refreshTokenrotation (a session is a token family). Mount the three handlers and drop in<SessionManager>:
// src/routes/api/auth/sessions/+server.ts
import { createListSessionsHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { GET } = createListSessionsHandler(authDeps);
// + sessions/revoke/+server.ts (createRevokeSessionHandler) and
// sessions/revoke-others/+server.ts (createRevokeOtherSessionsHandler)<script>
import { SessionManager } from '@urbicon-ui/auth';
</script>
<SessionManager basePath="/api/auth/sessions" />Revokes are ownership-scoped (a guessed family id can't sign out another user). The IP is shown only if you set config.sessions = { storeIp: true } (GDPR opt-in); the user-agent alone drives the "Browser · OS" device label.
- Two-factor (TOTP) — add an authenticator-app second factor. Set
config.twoFactor(theencryptionKeyis required — high-entropy, stable, e.g. 32 random bytes base64), providerepos.backupCode(the shipped adapters include it), mount the four handlers, and add<TwoFactorManager>for enrolment plus the verify path the two-step<LoginPage>posts to:
export const authDeps = createAuthDeps({
config: {
/* …jwt, appUrl… */
twoFactor: { encryptionKey: process.env.TWO_FACTOR_KEY! } // required when 2FA is on
},
repos, // must include repos.backupCode (createInMemoryRepos / createPrismaRepos do)
email
});// src/routes/api/auth/account/2fa/setup/+server.ts
import { createTwoFactorSetupHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { POST } = createTwoFactorSetupHandler(authDeps);
// + account/2fa/enable (createTwoFactorEnableHandler),
// account/2fa/disable (createTwoFactorDisableHandler), and the PUBLIC
// 2fa/verify route (createTwoFactorVerifyHandler) — the second login step.<script>
import { TwoFactorManager } from '@urbicon-ui/auth';
let { data } = $props(); // data.user from locals.user; user.totpEnabled drives the UI
</script>
<TwoFactorManager user={data.user}>
{#snippet qr({ uri })}<MyQrCode value={uri} />{/snippet}
</TwoFactorManager>Setup returns the otpauth:// URI + Base32 secret (the core ships no QR encoder to stay zero-dep — render it via the qr snippet, or let the user enter the key manually). Enrolment is two-step (setup → confirm a code), and enabling returns one-time backup codes. The secret is stored AES-256-GCM-encrypted; disable is password re-auth gated. The login handler gates automatically on user.totpEnabled — no extra wiring. Passkey logins are not gated (a passkey is already a strong factor). createAuthDeps injects a strict rateLimit.twoFactor default for the brute-force-critical verify step. The verify route must be public (default public routes already cover /api/auth/); make sure your route guard doesn't require a session for it.
- Federated identity / SSO — on the roadmap (
createFederatedAuthHandle()).
Security notes worth pinning
notification.urlis untrusted at navigation time. It originates from your event registry, but treat it as data: validate/allow-list it before passing it togoto()so a crafted URL can't drive an open redirect.- The console email transport is dev-only. It logs full email bodies (including reset/verify tokens) to stdout — never ship it to production.
createAuthHandleis mandatory for CSRF and session hydration. Route handlers alone don't apply the Origin/Double-Submit checks or setlocals.user. The Origin check covers only requests routed through the handle; a cross-origin, form-encoded endpoint bypassing it is instead gated by SvelteKit's own kernel CSRF check, which403s it before any hook (production only) — see docs/AUTH.md → Known Limitations.- Notification mark-read / delete must scope by the authenticated user. In those route handlers derive
userIdfromlocals.user, never from the request body — otherwise one user can mutate another's notifications (IDOR). recipients: 'admins'needs a resolver. The package has no role model, so passresolveAdminRecipients(e.g.() => repo.findAdminUserIds()) tocreateNotificationServicefor any type that targets admins. Without itsend()throws rather than silently dropping the alert. Push-delivery failures are swallowed (one bad subscription mustn't break a send) — passonPushResultto observe them; dead endpoints (410/404) are pruned automatically.
Prisma Schema
See prisma/auth-schema.prisma for the reference schema. Models: User, Invitation, PushSubscription, Notification, NotificationType, NotificationPreference, Passkey, TwoFactorBackupCode. Copy/merge into your app's schema.
Tests
630+ unit tests across 48 files — crypto primitives (JWT, HMAC, PBKDF2, CBOR, WebAuthn parsing, TOTP/HOTP/Base32 against the RFC-6238/4226 vectors, AES-256-GCM secret encryption), CSRF, rate-limiter, session cookies, validation, notification registry/SSE/Push, auth handlers (incl. the 2FA setup/enable/disable/verify flow + login gate), security headers, the service-worker notification-click handler, and the adapter conformance suite (atomic claim/scope guarantees — including backup-code single-use — run against both the in-memory and Prisma adapters).
cd packages/auth && bunx --bun vitest runPlaywright end-to-end coverage for the session kernel flow (e2e/auth.spec.ts) is in place since v0.11.0. Full WebAuthn attestation/assertion against a real authenticator and integration tests against a live Prisma instance remain out of scope for v1.0 — see docs/AUTH.md → Produktionsreife-Checkliste.
Known Limitations
Summarised here; full catalog with severity and fix-plan in docs/AUTH.md.
- Challenge store is pluggable. Default is an in-memory Map; pass a
ChallengeStoreimplementation viawebauthn.challengeStorefor Redis/Prisma/Upstash. - Rate-limit store is pluggable. Default is in-memory; pass a
RateLimitStoreimplementation viaconfig.rateLimit.*.storefor Redis/Prisma/Upstash. - Refresh-token store is pluggable. Default is in-memory (single-process only); pass a
RefreshTokenRepositoryviarepos.refreshTokenfor Prisma / Redis / Upstash. The Prisma adapter is bundled. - Double-Submit-Cookie CSRF is opt-in. Origin-check is always on for requests routed through
createAuthHandle(a separate, production-only SvelteKit kernel check guards handle-bypassed cross-origin form posts — see the linked catalog); to enable the additional token layer setconfig.csrf = { doubleSubmit: true }. Recommended for production. - Refresh-token rotation is opt-in. Without
config.refreshTokenthe package keeps the single-cookie 7-day JWT flow. Enable rotation viaconfig.refreshToken = {}plus a persistentrepos.refreshToken. - WebAuthn User-Verification (UV) is opt-in. UP is always enforced; enable UV by setting
webauthn.requireUserVerification: true. - Lockout is account-based, rate-limiting is per-IP. An attacker with many IPs can lock a known account (classic lockout-DoS trade-off). Set
lockout: nullto rely on the per-IP rate-limit alone; the default keeps lockout on as the higher-value protection. - 2FA verify is rate-limited per-IP, not per-account. Like the password login, the 2FA verify step keys its (strict, auto-injected) rate-limit on the client IP — consistent with the package's "per-IP default, account-lockout opt-in (DoS-prone)" stance. A distributed-IP attacker would still need the victim's password to obtain a pending-2FA cookie, and the ±1-window TOTP entropy (10⁶) plus the 5-min token TTL make it impractical; a per-account verify limiter is a deliberate later option, not a default.
- TOTP codes are replayable within their validity window. v1 relies on the strict verify rate-limit; a stored
lastUsedStepto reject same-window reuse is a noted later hardening. publicRoutesare prefixes.createAuthHandlematches them withstartsWith, so the default'/api/auth/'exempts every sub-route from the auth guard — don't place protected app routes under it.
Roadmap
The production-readiness milestone is shipped and stable (persistent-store adapters,
refresh rotation, CSRF, atomic adapter contract + conformance suite). The scope-conform
account clusters — account management, active-session listing, and TOTP two-factor —
have also shipped (beta). What's next:
- Federated Identity / SSO —
createFederatedAuthHandle(): ES256 + JWKS, building on the existing ECDSA/RSA primitives.
Development
bun --filter='@urbicon-ui/auth' run build # svelte-package
bun --filter='@urbicon-ui/auth' run check # svelte-check
cd packages/auth && bunx --bun vitest run # testsRelated
- docs/AUTH.md — architecture, security-gap catalog, consumer-migration notes
