Accepted
The admin interface (ADR-0006) is currently unprotected. ADR-0002 specifies "single-user, session-based" auth handled in SvelteKit hooks, but does not prescribe an implementation. This ADR supersedes the "single-user" assumption in ADR-0002: the design supports a small number of admin accounts (a family, a small team) without requiring a full user-management system.
-
Minimal setup friction. A self-hoster with Docker should go from
docker runto a secured admin in minutes. Environment-variable-based initial configuration (e.g. username/password) is acceptable and even desirable for the simplest path. -
No paid services or mandatory external dependencies. The app must remain fully self-contained. Optional integrations (e.g. OAuth / OpenID Connect with Google, Microsoft, GitHub) are welcome but must not be required.
-
No hand-rolled cryptography. Rely on established standards and platform-native primitives. Node.js built-in
cryptomodule (scrypt, SHA-256, randomBytes, timingSafeEqual) qualifies --- these are the same primitives that auth libraries call internally. -
Small user base, not single-user. The data model supports multiple accounts. During alpha/beta a shared admin account is acceptable.
-
Two-factor authentication is a strong nice-to-have. TOTP (authenticator app) support is deferred to Phase 2 but the schema reserves a
totp_secretcolumn from day one. -
Lightweight server-side state. SQLite-backed sessions via Drizzle ORM. No Redis, Memcached, or other infrastructure.
-
Idiomatic SvelteKit. SvelteKit hooks for route protection, form actions for login/logout,
event.localsfor request-scoped user data, server-only modules for secrets. -
Minimal dependency footprint. Phase 1 adds zero new npm packages. All cryptography uses Node.js built-in
crypto.
- In scope (Phase 1): admin route protection, login/logout flow, session handling.
- In scope (Phase 2, deferred): TOTP two-factor authentication.
- In scope (Phase 3, deferred): optional OAuth / SSO providers.
- Out of scope: public-facing user accounts, API tokens, fine-grained RBAC, audit logging.
SvelteKit provides all the building blocks for auth without a dedicated library:
hooks.server.tshandlefunction runs on every request --- validate session cookies and populateevent.locals.event.cookiesAPI with secure defaults (httpOnly, secure on production, sameSite: lax).- Form actions for login/logout mutations.
- Server-only modules (
$lib/server/) for secrets and auth logic.
| Library | Status | Size | Deps | Notes |
|---|---|---|---|---|
| Better Auth | Active, YC-backed | 4.34 MB | ~679 | Official SvelteKit recommendation for general-purpose apps. Drizzle + SQLite adapter. MIT. |
| Auth.js | Maintenance-only | 150 kB | ~463 | Being stewarded by Better Auth team. New projects should use Better Auth. |
| oslo.js | Stalled | Modular | 0 each | No commits in 2026. Single maintainer (pilcrowonpaper). |
| Lucia v3 | Deprecated (guide only) | N/A | N/A | Patterns remain the reference for DIY session auth. |
Better Auth is well-maintained and feature-rich but designed for apps with public sign-up flows, social login, email verification, and password reset emails. Tierdom needs none of that --- it protects an admin panel for 1--5 users. The dependency weight and schema-management opinions of Better Auth are disproportionate to the need.
Node.js built-in crypto.scryptSync is NIST-recommended and works on Alpine Docker without native addons.
Parameters: N=16384, r=8, p=1, keyLength=64 bytes.
Storage format: salt$hash (both hex-encoded), 16-byte random salt per password.
SQLite-backed sessions with a raw token in the cookie and its SHA-256 hash in the database. A database leak does not compromise active sessions. 30-day expiry with sliding window refresh (extend when less than 15 days remain).
Authelia and Authentik require separate containers, conflicting with the single-image constraint (ADR-0002). They remain a viable option for advanced self-hosters who already run a reverse proxy --- but not the default path.
Implement session-based auth using zero new npm dependencies, following the Lucia v3 "build it yourself" pattern with Node.js built-in crypto primitives.
Request
→ hooks.server.ts (read session cookie → SHA-256 → DB lookup)
→ event.locals.user / event.locals.session populated
→ /admin/* routes: redirect to /admin/login if unauthenticated
→ form actions for login/logout (SvelteKit-native)
user: id (UUID), username (unique), password_hash, totp_secret (nullable), timestamps.session: id (SHA-256 of token), user_id (FK), expires_at (Unix epoch), created_at.
- Self-hoster sets
ADMIN_PASSWORD(and optionalADMIN_USERNAME) environment variable. - On first boot, if no users exist, the app creates an admin account with the hashed password.
- Admin logs in at
/admin/login.
- Phase 1: password login + sessions + route protection (this ADR).
- Phase 2: TOTP two-factor authentication (separate ADR when implemented).
- Phase 3: optional OAuth SSO providers (separate ADR when implemented).
- Zero new production dependencies for Phase 1. No supply-chain risk, no package updates to track.
- All cryptographic primitives are Node.js built-ins (scrypt, SHA-256, timingSafeEqual, randomBytes). Battle-tested by millions of applications. Not hand-rolled --- we wire established primitives, not implement them.
- Total auth code is ~150 lines, fully auditable.
- The hook-based approach matches ADR-0006's design: admin routes remain auth-agnostic.
Adding auth does not modify any existing admin
+page.server.tsfile. - The
totp_secretcolumn is present but unused in Phase 1, avoiding a schema migration for Phase 2. - The
usertable supports multiple accounts from day one. ADR-0002's "single-user" assumption is superseded. - If requirements grow beyond what DIY auth can handle (e.g. public sign-up, email verification), Better Auth can be adopted for Phase 3 without rewriting Phase 1 --- the session table and admin routes stay the same.
A post-implementation security review identified brute-force attacks as the most significant real-world risk for an internet-facing login page.
Added: in-memory rate limiter (src/lib/server/auth/rate-limit.ts) that blocks an IP after 10 failed login attempts within a 1-minute window.
On successful login, the counter resets.
Stale entries are purged automatically every 15 minutes to prevent memory growth.
This is deliberately simple: in-memory state resets on server restart, which is acceptable for a single-process app with 1--5 users. A persistent (SQLite-backed) rate limiter would survive restarts but adds complexity that is not warranted at this stage.