Skip to main content

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 2fa branch: 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.19 for the upstream OTP-replay and session-cookie-splitting fixes.

Pinned exact, in lockstep. @better-auth/passkey is a peer that must match better-auth version-for-version. package.json overrides also pins @simplewebauthn/browser + @simplewebauthn/server to 13.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 token
  • emailOTP — generates an independent 6-digit code

They 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 rateLimit is unused. Auth paths are limited by an Upstash limiter in hooks.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:

  1. Magic link — Click link in email, instant sign in
  2. OTP code — Enter 6-digit code from email
  3. 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(), NOT signIn.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:

  • attemptsRemaining tracks client-side attempts (UX feedback only)
  • Server-side allowedAttempts: 3 in 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 source auth.user row 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

// 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.app is on the Public Suffix List, so a preview URL can never satisfy the production rpID. The exported passkeysEnabled flag (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-passkey in 1.6.19. Registration = GET /passkey/generate-register-options then POST /passkey/verify-registration. The client addPasskey() 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.tsonFactorChanged() composes three legs:

  1. AuditrecordAuditEvent with dot-namespaced actions (passkey.added/removed/renamed, 2fa.enabled/disabled/backup_codes_regenerated; targetType: 'auth.user'). Must land.
  2. Sibling-session revocation — hard-deletes the user's other sessions (keeps the current token; mirrors the grants.ts pattern). Degrades with logging.
  3. Notification emailfactorChangeTemplate. 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 enrollenable()totpURI + backup codes shown once → QR via POST /api/me/two-factor/qr (server-rendered with the existing qrSvg()) → verifyTotp confirm → 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" + onMount isConditionalMediationAvailable() 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.ts endpoints that accept application/json and mutate data, you must implement one of:

  1. 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' } })

  2. Double Submit Cookie - generate token in hook, validate in endpoint

  3. 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 (createLimiter from $lib/server/api/rate-limit).
  • The hook calls authRatelimit.limit(event.locals.clientIp) on /api/auth/* before Better Auth runs.
  • event.locals.clientIp is stamped once in securityHeaders from event.getClientAddress() — downstream code reads it, never the attacker-mutable forwarded-for chain.
  • Better Auth's advanced.ipAddress.ipAddressHeaders is 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 revokeSiblings leg of onFactorChanged (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.



Sources

← Back to Blueprint

Think this pattern could be better? Tell us how.

Leave feedback