Login Security
Implementation details for IP resolution, rate limiter secrets, OAuth HMAC validation, session tracking, accessibility fixes, and new auth methods (API Keys, Magic Links, Guest Auth, WebAuthn).
On this page
This document describes the SveltyCMS authentication flow and the accessibility work on the login screen. The numeric self-assessment lives on the security index.
Security Improvements
1. Audit Log & Rate Limiting IP Resolution
Client IP address resolution is centralized through getClientIp(event) (src/utils/hook-utils.ts:266) across all authentication, rate limiting, and audit logging flows. Rather than trusting raw X-Forwarded-For request headers directly (which is vulnerable to client-side IP spoofing), getClientIp enforces reverse-proxy boundary verification and memoizes the result on event.locals.__clientIp so the IP is resolved at most once per request instead of 3–5× per hook chain. Scanner rule ip-spoofing-x-forwarded-for in scripts/scan-security-risk.ts enforces this across the entire server codebase. Additionally, session records capture the resolved IP address and User-Agent at creation time for Active Device tracking.
2. OAuth State HMAC Verification
The Google OAuth flow (google-auth.ts) has been hardened using HMAC-SHA256 signatures for the state parameter.
- Generation: The state payload is signed during
generateGoogleAuthUrl. - Verification: The signature is verified on the callback (
signInOAuth) usingverifyOAuthState. This prevents OAuth state manipulation and ensures the integrity of the invitation token during the OAuth flow.
3. Separate Rate Limiter Secret
The RateLimiter cookie signature has been decoupled from the primary JWT_SECRET_KEY. It now uses RATE_LIMIT_SECRET with a secure fallback (JWT_SECRET_KEY + "-ratelimit"). This compartmentalization prevents a compromised rate-limit secret from jeopardizing the entire authentication system.
4. RFC 6585 Compliance (Retry-After Header)
All endpoints utilizing the RateLimiter now correctly inject the Retry-After: 60 HTTP header upon returning a 429 Too Many Requests status, ensuring compliance with RFC 6585 and properly signaling clients to back off.
5. Demo Tenant Capacity Limit
To prevent resource exhaustion during demo environments, a strict cap of 100 users per tenant is enforced during the signUp action. This leverages the getUserCount method from the authentication adapter to provide an early exit if the limit is reached.
6. Account Lockout & Brute-Force Prevention
Accounts are locked for 15 minutes after 5 consecutive failed login attempts. This is enforced in AuthNamespace.login() (src/services/sdk/namespaces/auth-namespace.ts) which gates the REST and GraphQL login paths, and redundantly in Auth.authenticate() (src/databases/auth/index.ts) for direct database authentication calls.
- Lockout check: Before password verification,
lockoutUntilis checked. Active lockouts return 403 with remaining time. - Failed attempt tracking: Each failed attempt increments
failedAttempts. At 5 failures,lockoutUntilis set tonow + 15 minutes. - Auto-reset: Lockout state is cleared on successful login or when the lockout expires.
- Consistent error messages & timing-safe normalization (CWE-208): Both “user not found” and “wrong password” return identical
"Invalid credentials". Furthermore, when a user is not found,AuthNamespace.loginexecutesverifyDummyPasswordagainst a static Argon2id hash (DUMMY_ARGON2_HASH), ensuring identical response latency (~50–80ms) and preventing email enumeration via timing side-channels.
7. Password-Reset Token Expiry (Diagnosable)
Auth.consumeToken() / adapter consumeToken() refuse expired tokens (expires > now on claim). Failures return structured codes for the login UI:
| Code | UI toast |
|---|---|
TOKEN_EXPIRED |
“This reset link has expired. Request a new one…” |
TOKEN_ALREADY_CONSUMED |
“This reset link was already used…” |
TOKEN_NOT_FOUND |
Generic invalid link |
- SQL:
relational-auth.ts— atomic update withgt(expires, now), then diagnose. - Mongo: claim filter includes
expires: { $gt: new Date() }. - UI:
sign-in.sveltehandleResetSubmitmapsresult.codeto toasts (no false success toast). - Remote:
auth.remote.tsresetPWInternalforwardscodeto the client. - E2E:
tests/e2e/routes/login/extended-auth.spec.ts—seed-expired-password-resettesting action seeds a past-expirypassword_resettoken; submit asserts Link expired toast (no soft-skip).
Accessibility Fixes (WCAG 2.2 AA)
To ensure the authentication pages are accessible to all users, the following fixes were implemented on sign-in.svelte and sign-up.svelte:
-
A1: Removed
role="button"from the main<section>element. -
A2: Added programmatic focus to the
#twofa-codeinput field usingtick()when Two-Factor Authentication is required (see Authentication documentation). -
A5/P7: Implemented
prefers-reduced-motionCSS media queries to disable animations (e.g., the.wiggleeffect) for users who prefer reduced motion. -
A6: Added skip links (
<a href="#signin-form" class="sr-only focus:not-sr-only">Skip to form</a>) at the top of the authentication forms for keyboard navigation. -
A8: Improved contrast for hint texts by changing
text-surface-500totext-surface-600 dark:text-surface-400. -
A9: Enhanced visual focus indicators by adding
:focus-visibleoutline styles to the main section. -
A10: Added
<svelte:head><meta name="robots" content="noindex, nofollow" /></svelte:head>to+page.svelteto prevent search engines from indexing the login page.
Session & Device Tracking
SveltyCMS captures device information at session creation for security auditing and the Active Devices UI.
Device Info Capture
When a user logs in, the following data is captured and stored with the session record:
- User-Agent: Full browser and OS information (e.g.,
Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/...) - IP Address: Resolved from
X-Forwarded-FororX-Real-IPheaders (proxy-aware)
Active Devices UI
Users can view and manage their active sessions from User Settings → Security (/user):
- View: Sessions grouped by device/browser (user-agent parsed into Mac / PC / phone / tablet with browser label), last activity time, and current-session indicator
- Revoke: Per-device revoke (signs out that browser immediately) or “revoke all others” — cross-session revokes require re-authentication with your current password (5-min proof, see Session Validation Layers); the action purges the session from all cache layers (turbo, LRU, Redis, database)
- Auto-Cleanup: Sessions not belonging to the password-changer are automatically invalidated on password change; blocked/deleted users are cut off immediately (see Session Validation Layers)
Session Device Policy
By default each user holds one active session per device (SESSION_DEVICE_POLICY = "single-per-device"): a new login on a device invalidates that device’s previous session (exact user-agent match), while other devices stay signed in. Re-login on the same device reuses the existing session where possible instead of churning new ones.
The policy is enterprise-configurable via System Settings → Security → Session Device Policy (seeded by default, editable per deployment):
| Value | Behavior |
|---|---|
single-per-device (default) |
One session per user per device — new logins evict the previous session of that device (same user-agent); other devices are unaffected |
single-per-user |
One active session per user in total — any new login evicts all other sessions across all devices |
allow-multiple |
No eviction — unlimited concurrent sessions (dedup/reuse disabled) |
Implementation notes:
- Eviction is best-effort and never blocks a login (availability-first; failures are logged and the session proceeds).
- An evicted session is purged from every layer: database row, in-memory/Redis session store, session cache, and turbo-auth context.
- Rotated sessions and the freshly created session are never evicted.
- Device identity is the exact user-agent string captured at login — two browsers on one machine count as different devices; the same browser on two machines counts as the same device class. IP is stored for auditing but deliberately not used for device matching (privacy + NAT friendliness).
- Device/IP data lives only as long as the session row; expired rows are purged by the background
session-cleanupqueue task (every 5 minutes). - Logins without a user-agent (legacy clients) skip eviction entirely.
Session Validation Layers & Lifecycle
Session validation is a 4-layer pipeline (fastest first):
- Turbo auth context (in-memory, 60 seconds, sliding on every hit; 15 minutes when
BENCHMARK=true) — GET/HEAD/OPTIONS fast path. A hit skips the database validation and still compares the request user-agent with the user-agent bound to the session. - Session cache (in-memory LRU, 10k entries) — validated sessions with a 24h trust window. Warm hits are 0 DB round-trips.
- Distributed cache (Redis
session:{tenant}:{id}) + in-memory/Redis session store — cross-node fallback. - Database — cold path, single-flight coalesced (concurrent cold requests await one validation instead of racing). All four adapters implement
auth.validateSessionas a single JOIN (auth_sessions⋈auth_users). On success the user is reused; on definitive miss the session is invalid; on transient failure the cookie is kept. The two-stepgetSessionTokenData+getUserByIdpath is only used whenvalidateSessionis absent (unit-test mocks). Production adapters never fall through to those extra round-trips.
Lifecycle semantics:
- Definitive invalidation (expired, revoked, user deleted, session row gone) → cookie deleted immediately.
- Transient failures (DB blip, in-flight coalesce) → request proceeds unauthenticated but the cookie is kept; the next request retries. A momentary failure never logs the user out.
- Blocked users are cut off immediately:
block/unblock/deletepurges every session layer for the affected users (LRU cache, turbo, session store, Redis) and the DB re-validation path enforces theblockedflag. Deleted users are likewise cut off at validation time. - Session lifetime (
SESSION_TTL_HOURS, default 24h, configurable in System Settings → Security) is applied at login and session rotation — rotation never extends the session beyond the configured policy. - Idle timeout (
SESSION_IDLE_HOURS, default 0 = disabled) signs the session out after N hours without any request. It rides entirely on the session-cache LRU timestamps (sliding on every authenticated request, enforced on warm hits and distributed entries) — zero extra queries or writes. After a restart the clock restarts from the first validated request; the absolute lifetime still bounds the session. - Token rotation (every 15 minutes,
SESSION_ROTATION_INTERVAL_MS): the successor id replaces the cookie and the predecessor row is retired for a 2-minute grace window instead of being deleted. Requests already in flight (SvelteKit data preloads, navigations, a second tab) still carry the pre-rotation cookie; a hard delete made them resolve to “invalid session”, and the hook then cleared every session cookie — the random sign-out that was most visible right after a process restart, which wipes the in-memoryrecentRotationsmap. The retired row expires on its own (adapterupdateSessionExpiry), so the grace survives restarts and works across nodes. Rotation is not a login:createSession({ isRotation: true })skips the device policy (SESSION_DEVICE_POLICY) and theSESSION_MAX_PER_USERcap, which must never evict the session being rotated away from. - Re-authentication for session management (Laravel-style): revoking any other session (device revoke, “sign out all others”) requires the current password.
POST /api/user/sessions/reauthverifies it and returns a stateless HMAC proof (5 minutes, bound to the current session,JWT_SECRET_KEY-signed, constant-time verified). Ending the current session (logout) needs no proof. A stolen session therefore cannot revoke the real user’s other devices. - Admin session console (API):
GET /api/user/sessions?admin=1&userId=Xlists another user’s active sessions (admins only);DELETE /api/user/sessions/:id?admin=1revokes without a re-auth proof. UI is planned (Access Management). - Negative Bloom cache short-circuits repeat lookups of revoked/expired sessions (~2392x speedup on repeated misses); the 60s refresh cooldown prevents DB stampedes.
- Credential-free session snapshots: session caches and the in-memory/Redis session store never retain credential material (
passwordhash,totpSecret,backupCodes,resetToken,googleRefreshToken,twoFactorTrustedDevices). The stripping happens at every cache/store write boundary (hook + storeset()), solocals.userexposes identity, role, and permissions only. Password-verifying endpoints (verify-password, 2FA disable, session re-auth) always fetch a fresh user from the DB before comparing hashes — the snapshot never doubles as a credential oracle. - Credential-free layout payload:
(app)/+layout.server.tsserializespage.data.userinto every admin HTML response, so the fresh DB re-read ingetFreshLayoutUser()(src/utils/server/layout-caches.server.ts) appliestoSafeSessionUser()before caching and returning it. Without that choke point the rawauth.getUserByIdrow — including the argon2idpasswordhash — rode into the client payload ("password":"$argon2id$…"was present in/config,/dashboard,/userand/mediagalleryHTML). Pinned bytests/unit/utils/layout-caches.server.test.ts. - Session context: the bound IP and user-agent are compared with the current request on a database validation, a warm session-cache hit, and a turbo-auth hit. A user-agent change steps mutations up (
403 SESSION_RISK_STEP_UPon/api, redirect to/login?reason=step-upon pages) untilPOST /api/user/sessions/reauthverifies the password and adopts the current browser. An IP-only change is logged once per session per hour ([Auth] Session context change) and does not block the request, so a rotating mobile address does not lock the session. Empty stored values stay allowed. GET, HEAD, and OPTIONS stay available./api/auth/login,/api/auth/logout,/login, and the reauth route stay reachable. - Max sessions per user (
SESSION_MAX_PER_USER, default 0 = unlimited): when the cap is exceeded at login, the least recently active session is evicted across all layers (Keycloak-style). Complements the device policy — recommended together withallow-multiple.
API Endpoints
GET /api/user/sessions— List all active sessions for the authenticated userDELETE /api/user/sessions/:sessionId— Revoke a specific session across all cache layers
Auth Methods
The SveltyCMS auth surface has been expanded with 4 additional authentication methods. See the Authentication System Architecture for full details and security considerations:
- API Keys (
sck_*): Machine-to-machine bearer tokens with SHA-256 hashed storage, scoped permissions, admin REST API, and instant cache invalidation on revoke. - Magic Links: Passwordless email login via single-use tokens with 15-min TTL, TOCTOU-safe consumption, and anti-enumeration responses.
- Guest Auth: Ephemeral
ANONYMOUS_USERon public routes with read-onlyguestrole — stateless, no session created. - WebAuthn/Passkeys (~75%): Biometric login via platform authenticators — challenge/response ceremony complete, COSE→JWK attestation, CBOR parsing. Settings-page UI pending.