Passwordless session-based authentication using Better Auth with Drizzle ORM.
Technology: Session-based auth (library, not a service). See stack/auth/ for alternatives.
No passwords. Users authenticate via magic link, OTP code (both sent in one email), OAuth, or passkey. This eliminates password-related security risks and simplifies the auth flow.
Two phishing-resistant additions landed on the
2fabranch: passkeys as a first-factor sign-in credential, and TOTP as a step-up factor for sensitive actions only. See Passkeys & Step-Up TOTP.
Strategy
Better Auth for production-ready passwordless authentication.
| Component | Technology | Provider | Why |
|---|---|---|---|
| Framework | Session auth | Better Auth | TypeScript-first, batteries-included |
| Primary auth | Magic link + OTP | Built-in plugins | No passwords to breach |
| Adapter | ORM integration | Drizzle | Native integration, auto-schema |
| Sessions | Database sessions | Neon | Immediate revocation, no JWT complexity |
| 2FA | TOTP | Built-in | Layer on top of passwordless |
| OAuth | OAuth 2.0 | Built-in | 20+ providers supported |
Why Passwordless
| Concern | Password-based | Passwordless |
|---|---|---|
| Credential stuffing | Vulnerable | Immune |
| Weak passwords | Common problem | Non-issue |
| Password reuse | Major risk | Non-issue |
| Database breach impact | High (hashes leaked) | Low (no secrets) |
| User friction | Forgot password flow | Just request new link |
| Support burden | Password resets | Minimal |
Why Better Auth
| Feature | Better Auth | DIY | Clerk |
|---|---|---|---|
| Setup time | Minutes | Hours | Minutes |
| Magic link + OTP | Built-in plugins | Manual | Built-in |
| Drizzle adapter | Native | Manual | N/A |
| Cost | Free | Free | $25/mo after 10K |
| Vendor lock-in | None | None | High |
Dependencies
"better-auth": "1.6.19",
"@better-auth/passkey": "1.6.19"
Bumped
1.6.17 → 1.6.19for the upstream OTP-replay and session-cookie-splitting fixes.
Pinned exact, in lockstep.
@better-auth/passkeyis a peer that must matchbetter-authversion-for-version.package.jsonoverridesalso pins@simplewebauthn/browser+@simplewebauthn/serverto13.2.x.
See development-environment.md for installation workflow.
Server Configuration
Auth Instance
Important: Better Auth has TWO separate plugins for passwordless auth:
magicLink— generates a clickable URL with 32-char tokenemailOTP— generates an independent 6-digit codeThey are independent plugins. Each sends its own email: the magic-link email and the OTP email are separate sends.
Rate limiting is not configured here. Better Auth's built-in
rateLimitis unused. Auth paths are limited by an Upstash limiter inhooks.server.ts(createLimiter('ratelimit:auth', ...)). See Rate Limiting.
// src/lib/server/auth/index.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { magicLink } from 'better-auth/plugins/magic-link';
import { emailOTP } from 'better-auth/plugins/email-otp';
import { db } from './db';
import { sendAuthEmail } from './send-auth-email';
import {
SESSION_EXPIRES_IN,
MAGIC_LINK_EXPIRES_IN,
EMAIL_OTP_EXPIRES_IN,
} from './config';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
// NO emailAndPassword - we use magic link + OTP only
emailAndPassword: {
enabled: false,
},
plugins: [
// Magic Link plugin - sends the clickable email link
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendAuthEmail({ to: email, magicLinkUrl: url });
},
expiresIn: MAGIC_LINK_EXPIRES_IN, // 300s (5 minutes)
}),
// Email OTP plugin - sends the 6-digit code in its own email
emailOTP({
otpLength: 6,
expiresIn: EMAIL_OTP_EXPIRES_IN, // 300s (5 minutes)
allowedAttempts: 3, // Lock out after 3 failed attempts per code
sendVerificationOnSignUp: true,
sendVerificationOTP: async ({ email, otp }) => {
await sendAuthEmail({ to: email, otpCode: otp });
},
}),
],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
session: {
expiresIn: SESSION_EXPIRES_IN, // 60*60*24*7 — 7 days
updateAge: 60 * 60 * 24, // Update session every 24 hours
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes - revalidate session from DB every 5 min
},
},
});
export type Auth = typeof auth;
Security notes:
allowedAttempts: 3— OTP codes are invalidated after 3 failed attempts (per-code lockout)- Magic link and OTP are cryptographically independent — compromising one doesn't reveal the other
- Expiries live in
config.ts:MAGIC_LINK_EXPIRES_IN/EMAIL_OTP_EXPIRES_IN(300s each),SESSION_EXPIRES_IN(7 days)
Auth Email Template
// src/lib/server/email.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
interface AuthEmailParams {
to: string;
subject: string;
magicLinkUrl: string;
otpCode: string;
}
export async function sendAuthEmail({ to, subject, magicLinkUrl, otpCode }: AuthEmailParams) {
await resend.emails.send({
from: 'Velociraptor <auth@yourdomain.com>',
to,
subject,
html: `
<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
<h2>Sign in to Velociraptor</h2>
<p>Click the button to sign in instantly:</p>
<a href="${magicLinkUrl}"
style="display: inline-block; background: #000; color: #fff;
padding: 12px 24px; text-decoration: none; border-radius: 6px;">
Sign In
</a>
<p style="margin-top: 24px; color: #666;">
Or enter this code:
</p>
<div style="font-size: 32px; font-family: monospace; letter-spacing: 4px;
background: #f5f5f5; padding: 16px; text-align: center;">
${otpCode}
</div>
<p style="margin-top: 24px; font-size: 14px; color: #999;">
This link expires in 5 minutes.
If you didn't request this, you can safely ignore this email.
</p>
</div>
`,
});
}
Email scanner note: Some enterprise email systems (Microsoft Defender, etc.) prefetch URLs which can "consume" magic links before users click them. The OTP code provides a reliable fallback. See GitHub #5550.
SvelteKit Hook
Important: Always add rate limiting BEFORE auth handlers. Auth operations are expensive (database lookups). See middleware.md for the complete hook sequence with rate limiting, CORS, CSRF, and security headers.
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { auth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment';
import { authLimiter, rateLimitResponse } from '$lib/server/api/rate-limit';
// Rate limiting (BEFORE auth - block brute force early)
const rateLimitHandle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/auth')) {
const { success, reset } = await authLimiter.limit(event.getClientAddress());
if (!success) return rateLimitResponse(reset);
}
return resolve(event);
};
// Better Auth handler
const authHandle = async ({ event, resolve }) => {
return svelteKitHandler({ event, resolve, auth, building });
};
// Populate event.locals with session (optional but recommended)
const sessionHandle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });
event.locals.user = session?.user ?? null;
event.locals.session = session?.session ?? null;
return resolve(event);
};
// Order: Rate limit FIRST, then auth
export const handle = sequence(rateLimitHandle, authHandle, sessionHandle);
Why sequence? SvelteKit only allows one handle export. Use sequence from @sveltejs/kit/hooks to compose multiple handlers (auth, CORS, logging, etc.).
Client Configuration
Auth Client
// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/svelte';
import {
magicLinkClient,
emailOTPClient,
twoFactorClient,
passkeyClient,
} from 'better-auth/client/plugins';
export const authClient = createAuthClient({
// SSR-safe: no window during server render
baseURL: typeof window !== 'undefined' ? window.location.origin : '',
// No adminClient(): the server-side admin() plugin is deliberately not enabled.
// Admin authority is the ADMIN_USER_ID env list — see blueprint/security/topology.md.
plugins: [
magicLinkClient(),
emailOTPClient(),
twoFactorClient(),
passkeyClient(),
],
});
// Export typed helpers
export const {
signIn,
signOut,
useSession,
emailOtp, // For OTP verification: emailOtp.verifyOtp()
} = authClient;
Session Access Pattern
Primary pattern: Access session via event.locals (populated in hooks) and page data.
// src/routes/[[locale=locale]]/account/+layout.server.ts
export async function load({ locals }) {
return {
user: locals.user,
session: locals.session,
};
}
<!-- src/routes/[[locale=locale]]/account/+layout.svelte -->
<script>
import { page } from '$app/state';
const user = $derived(page.data.user);
const isAuthenticated = $derived(!!page.data.user);
</script>
{#if isAuthenticated}
<p>Welcome, {user.name}!</p>
{/if}
Why not module-level useSession()? Module-level state is shared across SSR requests, creating a security risk where User A's session could leak to User B. The event.locals pattern is request-scoped and SSR-safe. See state.md for details.
The publicUser() projector
Never return raw locals.user from a load. Better Auth's user object carries fields that should not cross the server→client boundary. publicUser() ($lib/server/auth/public-user.ts) is the canonical client-safe shape:
publicUser(locals.user) // → { id, email, name, image, emailVerified } | null
Account, dashboard, and root-layout loads project through it. A repo-wide leak-gate test forbids any load from returning locals.user directly — the projector is the only sanctioned path. One definition of "what the client may see about a user", enforced, not left to each load.
Database Schema
Better Auth auto-generates tables. Run CLI to create migrations:
bunx @better-auth/cli generate
bunx drizzle-kit migrate
Generated Tables
// Better Auth creates these tables automatically:
// - user (id, email, emailVerified, name, image, createdAt, updatedAt)
// - session (id, userId, token, expiresAt, ipAddress, userAgent)
// - account (id, userId, providerId, providerUserId, accessToken, refreshToken)
// - verification (id, identifier, value, expiresAt)
Extending the Schema
// src/lib/server/db/schema/auth/_better-auth.ts
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
// Better Auth's user table (reference only - generated by CLI)
export const user = pgTable('user', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull().default(false),
name: text('name'),
image: text('image'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
// Your custom user fields (separate table)
export const userProfile = pgTable('user_profile', {
userId: text('user_id')
.primaryKey()
.references(() => user.id, { onDelete: 'cascade' }),
bio: text('bio'),
website: text('website'),
timezone: text('timezone').default('UTC'),
});
Authentication Flows
Passwordless only. No passwords in the system. Users authenticate via:
- Magic link — Click link in email, instant sign in
- OTP code — Enter 6-digit code from email
- OAuth — GitHub, Google, etc.
Both magic link and OTP are sent in the same email. User chooses their preferred method.
Email Entry (Step 1)
<!-- src/routes/auth/login/+page.svelte -->
<script lang="ts">
import { authClient, signIn } from '$lib/auth-client';
import { goto } from '$app/navigation';
let email = $state('');
let loading = $state(false);
let error = $state('');
async function handleSubmit() {
loading = true;
error = '';
const result = await authClient.signIn.magicLink({
email,
callbackURL: '/account/dashboard',
});
if (result.error) {
error = result.error.message;
loading = false;
return;
}
// Redirect to verification page
goto(`/auth/verify?email=${encodeURIComponent(email)}`);
}
async function handleOAuth(provider: 'github' | 'google') {
await signIn.social({ provider });
}
</script>
<form onsubmit={handleSubmit}>
<h1>Sign in</h1>
<div class="form-field">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
placeholder="you@example.com"
required
/>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Continue with Email'}
</button>
</form>
<div class="divider">or</div>
<div class="oauth-buttons">
<button type="button" onclick={() => handleOAuth('github')}>
Continue with GitHub
</button>
<button type="button" onclick={() => handleOAuth('google')}>
Continue with Google
</button>
</div>
OTP Verification (Step 2)
Important: OTP verification uses
emailOtp.verifyOtp(), NOTsignIn.magicLink(). Magic links and OTPs are cryptographically independent — different tokens, different verification methods.
<!-- src/routes/auth/verify/+page.svelte -->
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { goto } from '$app/navigation';
import { page } from '$app/state';
const email = $derived(page.url.searchParams.get('email') ?? '');
let code = $state('');
let loading = $state(false);
let error = $state('');
let attemptsRemaining = $state(3);
let resendCooldown = $state(0);
async function handleVerify() {
loading = true;
error = '';
// Use emailOtp.verifyOtp() - NOT signIn.magicLink()
const result = await authClient.emailOtp.verifyOtp({
email,
otp: code,
});
if (result.error) {
attemptsRemaining--;
if (attemptsRemaining <= 0) {
error = 'Too many failed attempts. Please request a new code.';
} else {
error = `Invalid code. ${attemptsRemaining} attempts remaining.`;
}
loading = false;
return;
}
goto('/account/dashboard');
}
async function handleResend() {
resendCooldown = 60;
attemptsRemaining = 3; // Reset attempts on new code
const interval = setInterval(() => {
resendCooldown--;
if (resendCooldown <= 0) clearInterval(interval);
}, 1000);
// Request new magic link (which also triggers new OTP)
await authClient.signIn.magicLink({
email,
callbackURL: '/account/dashboard',
});
}
// Auto-submit when 6 digits entered
$effect(() => {
if (code.length === 6 && !loading) {
handleVerify();
}
});
</script>
<div class="verify-page">
<h1>Check your email</h1>
<p>We sent a sign-in link to <strong>{email}</strong></p>
<p class="hint">Click the link in your email, or enter the 6-digit code below:</p>
<form onsubmit={handleVerify}>
<div class="otp-input">
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
bind:value={code}
placeholder="000000"
autocomplete="one-time-code"
/>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<button type="submit" disabled={loading || code.length !== 6 || attemptsRemaining <= 0}>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
<p class="resend">
Didn't receive it?
{#if resendCooldown > 0}
<span>Resend in {resendCooldown}s</span>
{:else}
<button type="button" onclick={handleResend}>Resend email</button>
{/if}
</p>
</div>
Security notes:
attemptsRemainingtracks client-side attempts (UX feedback only)- Server-side
allowedAttempts: 3in emailOTP config enforces the real limit- After 3 failed attempts, the OTP is invalidated — user must request a new code
Sign Out
<script lang="ts">
import { signOut } from '$lib/auth-client';
import { goto } from '$app/navigation';
async function handleSignOut() {
await signOut();
goto('/');
}
</script>
<button onclick={handleSignOut}>Sign Out</button>
Capability Grants
Role-based authorization (user.role === 'author') was replaced by a capability-grant system. Roles no longer encode permissions; grants do.
Tables
auth.grant — polymorphic, audited capabilities.
| Column | Notes |
|---|---|
user_id |
FK → auth.user(id) CASCADE |
kind |
Capability string. v1: 'blog-author' |
granted_by |
Admin actor ID (no FK, survives deletion) |
granted_at |
When granted |
revoked_at |
nullable. Partial UNIQUE: (user_id, kind) WHERE revoked_at IS NULL enforces at-most-one active grant per kind |
revoked_by |
nullable, admin actor ID |
notified_at |
nullable. Cleared when the grant notification Toast is consumed |
auth.grant_request — user-initiated access requests.
| Column | Notes |
|---|---|
user_id |
FK → auth.user(id) CASCADE |
kind |
Same kind space as auth.grant |
status |
pending | approved | denied | expired |
message |
Optional note from requester |
requested_at |
|
resolved_at |
nullable |
resolved_by |
nullable, admin actor ID |
Partial UNIQUE on (user_id, kind) WHERE status = 'pending' — one pending request per kind. Pending requests auto-expire after 14 days via the grant-request-expiry scheduled job.
Domain Modules
$lib/server/auth/
grants.ts — grantCapability, revokeCapability, hasGrant,
listActiveGrantKinds, consumePendingGrantNotifications
grant-requests.ts — createGrantRequest, approveRequest, denyRequest, expireOldRequests
Per-Request Populate
hooks.server.ts sessionPopulate sets event.locals.grants: GrantKind[] on every authenticated request (single PK-indexed query). App.Locals has a grants: GrantKind[] field.
On grant/revoke: the mutation deletes all sessions for the affected user. Next sign-in re-populates grants.
Guards
| Guard | Check | Replaces |
|---|---|---|
requireBlogAuthor(locals) |
locals.grants?.includes('blog-author') or admin |
requireAuthor |
guardApiBlogAuthor(locals) |
same, returns 401/403 JSON | requireApiAuthor |
guardApiBlogAuthor(locals) |
same, returns early | guardApiAuthor |
guardApiAdmin(locals) |
admin env check | (new) |
Admin (env-pinned via ADMIN_USER_ID, a comma-separated list of admin user ids — multiple admins) always passes all capability checks.
Admin Gate: 404, Not 403
guardApiAdmin and its page-load counterpart requireAdmin return a generic 404 for non-admins — never 403. A 403 confirms "this route exists, you're just not allowed"; an admin-only surface must not make that admission to an unauthenticated or under-privileged caller, so denied is made indistinguishable from absent:
// src/lib/server/auth/guards.ts (excerpt)
export function requireAdmin(locals: App.Locals) {
const user = requireAuth(locals);
if (!isAdmin(user)) throw error(404, 'Not Found'); // 404, NOT 403
return user;
}
Capability guards (requireBlogAuthor and friends) still throw a normal 403 — the 404 disguise is reserved for the admin boundary, where even acknowledging the surface exists is the leak. See Live showcase.
Audit Log: No Foreign Key by Design
admin.audit_log intentionally has no foreign key on actor_id, plus a denormalized actor_email:
- No FK — the trail must outlive the actor. A normal
onDelete: 'cascade'FK would erase an admin's audit history the moment their own account is deleted; omitting the FK keeps the log append-only in practice, not just in the code path. - Denormalized
actor_email— the log reads without a join, so entries stay legible even after the sourceauth.userrow is gone.
The module also exposes no update/delete function — see gdpr.md for the full admin-guarantees table this belongs to. See Live showcase.
API Endpoints
POST /api/grant-requests — create request (self-service, 1/24h rate limit)
GET /api/grant-requests — check own pending request
DELETE /api/grant-requests — cancel own request
GET /api/admin/grant-requests — list all pending (admin)
POST /api/admin/grant-requests/approve — approve
POST /api/admin/grant-requests/deny — deny
GET /api/admin/users/[id]/grants — list active grants for user
PUT /api/admin/users/[id]/grants/[kind] — grant capability
DELETE /api/admin/users/[id]/grants/[kind] — revoke capability
Namespace gotcha: The
/api/auth/*prefix is owned by Better Auth's catch-all handler (svelteKitHandler). Any routes you place under/api/auth/will 404 — Better Auth intercepts them before SvelteKit routes them. Grant-request endpoints live at/api/grant-requests(not/api/auth/grant-requests) for this reason. See stack/auth/better-auth.md for details.
Route Protection
Server-Side (Recommended)
// src/routes/account/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
export async function load({ request }) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
redirect(303, '/auth/login');
}
return {
user: session.user,
};
}
Helper Function
// src/lib/server/auth/guard.ts
import { redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import type { RequestEvent } from '@sveltejs/kit';
export async function requireAuth(event: RequestEvent) {
const session = await auth.api.getSession({
headers: event.request.headers,
});
if (!session) {
const returnTo = encodeURIComponent(event.url.pathname);
redirect(303, `/auth/login?redirect=${returnTo}`);
}
return session;
}
// src/routes/account/settings/+page.server.ts
import { requireAuth } from '$lib/server/auth/guard';
export async function load(event) {
const { user } = await requireAuth(event);
return { user };
}
Client-Side Guard
<!-- src/routes/[[locale=locale]]/account/+layout.svelte -->
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
const user = $derived(page.data.user);
$effect(() => {
if (browser && !user) {
goto('/auth/login');
}
});
</script>
{#if user}
<slot />
{:else}
<div>Loading...</div>
{/if}
Note: Server-side guards (in +page.server.ts) are preferred — they prevent the page from rendering at all. Client-side guards are a fallback for SPA navigation.
Passkeys & Step-Up TOTP
Two phishing-resistant factors, with different jobs:
- Passkey — a first-factor sign-in credential. User verification (biometric/PIN) makes it MFA in one gesture. Phishing-resistant by WebAuthn design.
- TOTP — a step-up factor only. It re-proves identity before a sensitive action. It is never a login challenge.
Why no TOTP-at-login
Better Auth's twoFactor plugin gates only credential sign-in endpoints (/sign-in/email|username|phone-number). This app is passwordless — there are no credential sign-ins — so a TOTP login challenge would never fire. That is upstream design (1.6.3 broadened the gating; 1.6.4 reverted it). So passkeys carry the phishing-resistant-login role, and TOTP is repurposed as step-up.
allowPasswordless: true is what lets credential-less users enroll TOTP at all (upstream's shouldRequirePassword skips the password gate when no credential account exists).
Security ceiling, stated honestly. Magic link and email OTP stay enabled as recovery, so inbox control still equals account control. Passkeys raise the floor (phishing-resistant primary path), not the ceiling.
Server config
// src/lib/server/auth/index.ts
twoFactor({
issuer: TWO_FACTOR_ISSUER,
allowPasswordless: true, // credential-less users can enroll
skipVerificationOnEnable: false,
}),
...(passkeysEnabled // see below
? [passkey({ rpID, rpName: 'Velociraptor', origin })]
: []),
rpID / origin derive from BETTER_AUTH_URL (dev → localhost).
Passkeys are disabled on Vercel previews at the plugin level — endpoints 404, not just hidden UI.
*.vercel.appis on the Public Suffix List, so a preview URL can never satisfy the productionrpID. The exportedpasskeysEnabledflag (env.VERCEL_ENV !== 'preview') also hides the UI in page loads.
The hooks chokepoint
authClient calls hit Better Auth's plugin endpoints directly, so form actions can't carry the audit/notify/revoke duties. Two global createAuthMiddleware hooks in auth/index.ts are the single un-skippable seam:
hooks.before — gates step-up-sensitive operations server-side. /two-factor/disable and /two-factor/generate-backup-codes require a fresh step-up (substitutes for the upstream password gate, which we don't have — open bug #9248).
hooks.after — branches on ctx.path to fire factor-change side effects:
| Endpoint | Side effect |
|---|---|
/passkey/verify-registration |
onFactorChanged('passkey.added') |
/passkey/delete-passkey |
'passkey.removed' + revoke sibling sessions |
/passkey/update-passkey |
'passkey.renamed' (audit only) |
/passkey/verify-authentication |
fire-and-forget lastUsedAt stamp |
/two-factor/enable |
'2fa.enabled' |
/two-factor/disable |
'2fa.disabled' + revoke sibling sessions |
/two-factor/generate-backup-codes |
'2fa.backup_codes_regenerated' |
/two-factor/verify-totp, /verify-backup-code |
stampStepUp(userId) |
No
/passkey/add-passkeyin 1.6.19. Registration =GET /passkey/generate-register-optionsthenPOST /passkey/verify-registration. The clientaddPasskey()wraps both.
Step-up freshness gate
auth/step-up.ts — framework-free, never imports the auth instance (cycle: auth/index.ts imports from here).
| Function | Purpose |
|---|---|
stampStepUp(userId) |
Set Redis stepup:<userId> for STEPUP_TTL (600s). |
isStepUpFresh(userId) |
True if the key exists. |
requireStepUp(user) |
Passes for users without TOTP; else requires a fresh stamp. |
The gate reads Redis, not the session — freshness must never ride the 300s session cookieCache. Fail-closed in prod when Redis is null (a missing key can only block); dev passes with a warning.
Factor-change side effects
auth/factor-changes.ts — onFactorChanged() composes three legs:
- Audit —
recordAuditEventwith dot-namespaced actions (passkey.added/removed/renamed,2fa.enabled/disabled/backup_codes_regenerated;targetType: 'auth.user'). Must land. - Sibling-session revocation — hard-deletes the user's other sessions (keeps the current token; mirrors the
grants.tspattern). Degrades with logging. - Notification email —
factorChangeTemplate. Degrades with logging.
A mail outage can never block a security event from being recorded.
Enrollment & management UI
Route /account/security (a Card link from /account):
- Passkeys — list / add / rename / delete, client-call-driven +
invalidateAll, not Superforms. Deliberate: matches the login/verify client-call precedent (the WebAuthn ceremony is a browser API call, not a form post). - TOTP enroll —
enable()→totpURI+ backup codes shown once → QR viaPOST /api/me/two-factor/qr(server-rendered with the existingqrSvg()) →verifyTotpconfirm →getSession({ disableCookieCache: true })→invalidateAll.
enable()is not idempotent — re-calling rotates the secret. The UI locks the button to prevent an accidental re-roll.
A dismissable enrollment nudge (sessionStorage, shown when the user has 0 passkeys) lives on the dashboard. In-flow prompts vastly outperform settings-only adoption.
Step-up dialog
Composite $lib/components/composites/step-up-dialog: TOTP-or-backup-code entry. Account-area actions (revokeSession on /account/security, deleteAccount on /account/settings) return fail(403, { stepUpRequired }) when the user is enrolled and stale → the dialog opens → the form resubmits after a fresh verification.
Login surface
- Passkey sign-in button.
- Conditional-UI autofill:
autocomplete="username webauthn"+onMountisConditionalMediationAvailable()check. A user-cancel (NotAllowedError) is treated as a silent dismiss.
Login DTO projection
db/user/queries.ts adds listPasskeyDtos (projects out publicKey / credentialID / counter / raw aaguid; resolves aaguid → a human label via getAuthenticatorName from @better-auth/passkey), countPasskeys, and touchPasskeyLastUsed.
Step-Up Rate Limiting
A per-account limiter (5 per 300s, Redis key ratelimit:2fa:verify) guards /two-factor/verify-totp, /verify-backup-code, and /verify-otp, keyed by session.user.id ?? clientIp.
The session is resolved inline in authHandler because sessionPopulate runs after it and /api/auth/* terminates inside authHandler (Better Auth's catch-all never reaches sessionPopulate).
'/api/auth/two-factor/send-otp' is added to AUTH_CAPTCHA_GATED_PATHS (it triggers an email send). See abuse/rate-limits.md.
Security
Built-in Protections
| Feature | Status |
|---|---|
| CSRF protection | Forms only (see warning below) |
| Session fixation | Handled |
| Secure cookies | advanced.useSecureCookies = NODE_ENV === 'production' (set explicitly, not inferred from the baseURL scheme) |
| Magic link expiry | 5 minutes (MAGIC_LINK_EXPIRES_IN) |
| Rate limiting | Upstash limiter in hooks (see below) |
| Session revocation | Manual sibling-session purge (see below) |
CSRF Warning: SvelteKit's built-in CSRF protection only covers form submissions (
application/x-www-form-urlencoded,multipart/form-data,text/plain). JSON API endpoints are NOT protected. For any+server.tsendpoints that acceptapplication/jsonand mutate data, you must implement one of:
Custom header check (simplest):
// In +server.ts if (!request.headers.get('x-requested-with')) { return json({ error: 'Missing CSRF header' }, { status: 403 }); }Client must send:
fetch(url, { headers: { 'x-requested-with': 'fetch' } })Double Submit Cookie - generate token in hook, validate in endpoint
Use form actions instead - covered by SvelteKit's automatic protection
Rate Limiting
Better Auth's built-in rateLimit is not used. Auth paths are limited by an Upstash limiter wired in hooks.server.ts.
authRatelimit = createLimiter('ratelimit:auth', AUTH_RATE_LIMIT_MAX, AUTH_RATE_LIMIT_WINDOW)— 5 per 60s (createLimiterfrom$lib/server/api/rate-limit).- The hook calls
authRatelimit.limit(event.locals.clientIp)on/api/auth/*before Better Auth runs. event.locals.clientIpis stamped once insecurityHeadersfromevent.getClientAddress()— downstream code reads it, never the attacker-mutable forwarded-for chain.- Better Auth's
advanced.ipAddress.ipAddressHeadersis pinned to['x-client-ip'], so any IP Better Auth records comes from that single trusted stamp.
// src/hooks.server.ts
const authRatelimit = createLimiter('ratelimit:auth', AUTH_RATE_LIMIT_MAX, AUTH_RATE_LIMIT_WINDOW);
const authHandler: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/auth/') && event.locals.clientIp) {
const { success, reset } = await authRatelimit.limit(event.locals.clientIp);
if (!success) return rateLimitResponse(reset);
}
return svelteKitHandler({ event, resolve, auth, building });
};
For additional protection - see abuse/rate-limits.md for per-email and per-IP limiters on magic-link and OTP send paths.
Session Revocation
There is no revokeOtherSessions config flag. Sibling-session revocation is manual:
- The
revokeSiblingsleg ofonFactorChanged(auth/factor-changes.ts) hard-deletes the user's other sessions on a factor change (keeps the current token). - The grant mutation path purges all sessions for the affected user so grants re-populate on next sign-in.
Session Cleanup (Required)
Important: Better Auth does NOT automatically clean up expired sessions. Without cleanup, your session table will grow indefinitely.
Create a cleanup job:
// src/lib/server/jobs/session-cleanup.ts
import { db } from '$lib/server/db';
import { session } from '$lib/server/db/schema';
import { lt } from 'drizzle-orm';
export async function cleanupExpiredSessions() {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 1); // 24h grace period
const result = await db
.delete(session)
.where(lt(session.expiresAt, cutoff))
.returning({ id: session.id });
return { deleted: result.length };
}
Create a cron endpoint:
Security: Use timing-safe comparison for cron secrets to prevent timing attacks.
// src/routes/api/cron/session-cleanup/+server.ts
import { json, error } from '@sveltejs/kit';
import { timingSafeEqual } from 'crypto';
import { CRON_SECRET } from '$env/static/private';
import { cleanupExpiredSessions } from '$lib/server/jobs/session-cleanup';
function verifyCronSecret(authHeader: string | null): boolean {
if (!authHeader || !CRON_SECRET) {
return false;
}
const expected = `Bearer ${CRON_SECRET}`;
// Length check first
if (authHeader.length !== expected.length) {
return false;
}
return timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected));
}
export async function GET({ request }) {
const auth = request.headers.get('authorization');
if (!verifyCronSecret(auth)) {
error(401, 'Unauthorized');
}
const result = await cleanupExpiredSessions();
return json({ success: true, ...result });
}
Schedule in vercel.json:
{
"crons": [{
"path": "/api/cron/session-cleanup",
"schedule": "0 2 * * *"
}]
}
File Structure
src/
├── lib/
│ ├── server/
│ │ ├── auth/
│ │ │ ├── index.ts # Better Auth instance + before/after hooks (construction site)
│ │ │ ├── guards.ts # Route protection helpers
│ │ │ ├── grants.ts # Capability grant/revoke/query
│ │ │ ├── grant-requests.ts # Request lifecycle
│ │ │ ├── step-up.ts # Redis step-up freshness gate + twoFactorVerifyLimitKey (no auth import)
│ │ │ ├── factor-changes.ts # Audit + revoke + notify chokepoint
│ │ │ ├── public-user.ts # publicUser() — client-safe {id,email,name,image,emailVerified}
│ │ │ └── send-auth-email.ts # Magic-link / OTP / factor-change templates
│ │ └── db/user/queries.ts # listPasskeyDtos, countPasskeys, touchPasskeyLastUsed
│ ├── components/composites/step-up-dialog/ # TOTP / backup-code re-verify
│ └── auth-client.ts # twoFactorClient() + passkeyClient()
├── routes/
│ ├── [[locale]]/
│ │ ├── auth/
│ │ │ ├── login/+page.svelte # Email entry + passkey button + conditional-UI
│ │ │ └── verify/+page.svelte # OTP verification
│ │ └── app/account/security/ # Passkey + TOTP enrollment
│ └── api/me/two-factor/qr/+server.ts # Server-rendered TOTP QR (qrSvg)
└── hooks.server.ts # Auth handler + per-account 2FA verify limiter + session/grants populate
Environment Variables
# .env
BETTER_AUTH_URL=http://localhost:5173 # drives passkey rpID / origin
BETTER_AUTH_SECRET=... # ≥32 chars (openssl rand -base64 32)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
Summary
| What | How |
|---|---|
| Auth framework | Better Auth |
| Primary auth | Magic link + OTP (passwordless) |
| Session storage | PostgreSQL via Drizzle |
| OAuth providers | GitHub, Google (built-in) |
| Passkey | First-factor sign-in credential (phishing-resistant) |
| TOTP | Step-up factor for sensitive actions — never a login challenge |
| Step-up gate | Redis stepup:<userId>, 600s freshness, fail-closed in prod |
| Route protection | Per-route in +page.server.ts |
| Session access | event.locals → page data (SSR-safe) |
Alternative: DIY Sessions
For learning or maximum control, see The Copenhagen Book and use:
| Package | Purpose |
|---|---|
@oslojs/crypto |
SHA-256 hashing (for tokens) |
@oslojs/encoding |
Base32/Hex encoding |
arctic |
OAuth providers |
This approach requires implementing sessions, cookies, magic link flows, and OAuth manually.
Related
- ../foundation/user-data.md - Data category definitions
- db/README.md - Data layer overview
- db/relational.md - Schema including Better Auth tables
- api.md - Protected API endpoints
- pages.md - Auth routes (
/auth/*) and protected routes (/account/*)