ideal-auth 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2000 @@
1
+ ---
2
+ name: ideal-auth
3
+ description: Auth primitives for the JS ecosystem. Covers login, logout, session, register, signup, sign in, sign out, password, 2FA, two-factor, TOTP, MFA, ideal-auth, cookie bridge, middleware, route protection, rate limit, rate limiting, password reset, forgot password, email verification, remember me, recovery code, CSRF, encrypt, decrypt, hash, bcrypt, token, secret, session cookie, multi-tenant, cross-domain, tenant, transfer token, federated logout, OAuth redirect, central login, login session, attemptUser, sessionFields, cookie-backed session, resolveUser, token refresh, refresh token, access token, OIDC.
4
+ ---
5
+
6
+ You are an expert on `ideal-auth`, the auth primitives library for the JS ecosystem. You have complete knowledge of its API, patterns, security model, and framework integrations. Use this knowledge to help users implement authentication correctly.
7
+
8
+ When the user asks to "set up auth" or "add authentication" in a project that has `ideal-auth` installed, use the AskUserQuestion tool to detect their setup:
9
+
10
+ ```
11
+ questions:
12
+ - question: "Which framework are you using?"
13
+ header: "Framework"
14
+ options:
15
+ - label: "Next.js (App Router)"
16
+ description: "React framework with Server Actions and middleware"
17
+ - label: "SvelteKit"
18
+ description: "Svelte framework with form actions and hooks"
19
+ - label: "Express"
20
+ description: "Node.js HTTP framework"
21
+ - label: "Hono"
22
+ description: "Lightweight framework for Node, Bun, Deno, Workers"
23
+ multiSelect: false
24
+
25
+ - question: "Which auth features do you need?"
26
+ header: "Features"
27
+ options:
28
+ - label: "Login + Registration"
29
+ description: "Email/password auth with session management"
30
+ - label: "Password Reset"
31
+ description: "Forgot password flow with email tokens"
32
+ - label: "Two-Factor Auth (TOTP)"
33
+ description: "Authenticator app + recovery codes"
34
+ - label: "Rate Limiting"
35
+ description: "Brute-force protection on login"
36
+ multiSelect: true
37
+ ```
38
+
39
+ Additional framework options to offer if the user picks "Other": Nuxt, TanStack Start, Elysia.
40
+
41
+ ---
42
+
43
+ # ideal-auth Complete Reference
44
+
45
+ ## Overview
46
+
47
+ Auth primitives for the JS ecosystem. Zero framework dependencies. Inspired by Laravel's `Auth` and `Hash` facades.
48
+
49
+ - **Sessions**: Stateless, encrypted cookies via iron-session (AES-256-CBC + HMAC integrity)
50
+ - **Passwords**: bcrypt via bcryptjs with SHA-256 prehash for passwords > 72 bytes
51
+ - **Tokens**: HMAC-SHA256 signed, expiring tokens for password reset, email verification, magic links
52
+ - **TOTP**: RFC 6238 two-factor authentication with recovery codes
53
+ - **Rate Limiting**: Pluggable store (in-memory default, Redis/DB for production)
54
+ - **Crypto**: AES-256-GCM encryption, HMAC signing, timing-safe comparison
55
+ - **Cookie Bridge**: 3-function adapter — works with any framework
56
+
57
+ Install: `bun add ideal-auth` (or `npm install ideal-auth`)
58
+
59
+ Generate secret: `bunx ideal-auth secret` (outputs `IDEAL_AUTH_SECRET=...` for `.env`)
60
+
61
+ Generate encryption key: `bunx ideal-auth encryption-key` (for encrypting TOTP secrets at rest)
62
+
63
+ ---
64
+
65
+ ## API Reference
66
+
67
+ ### `createAuth(config): () => AuthInstance`
68
+
69
+ Returns a factory function. Call `auth()` per request to get an `AuthInstance` scoped to that request's cookies. The instance caches the session payload and user — call it once per request and reuse.
70
+
71
+ #### AuthConfig
72
+
73
+ ```typescript
74
+ type AuthConfig<TUser extends AnyUser> = {
75
+ secret: string; // 32+ chars, required — throws if shorter
76
+ cookie: CookieBridge; // required
77
+
78
+ // Session mode — provide exactly ONE of these two:
79
+ resolveUser?: (id: string) => Promise<TUser | null>; // DB-backed: user() calls this every request
80
+ sessionFields?: (keyof TUser & string)[]; // Cookie-backed: user() reads from cookie, zero external calls
81
+
82
+ // Session options
83
+ session?: {
84
+ cookieName?: string; // default: 'ideal_session'
85
+ maxAge?: number; // default: 604800 (7 days, in seconds)
86
+ rememberMaxAge?: number; // default: 2592000 (30 days, in seconds)
87
+ cookie?: Partial<ConfigurableCookieOptions>;
88
+ };
89
+
90
+ // Laravel-style attempt (recommended)
91
+ hash?: HashInstance;
92
+ resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
93
+ credentialKey?: string; // default: 'password'
94
+ passwordField?: string; // default: 'password'
95
+
96
+ // Manual attempt (escape hatch — takes precedence if both provided)
97
+ attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null>;
98
+ };
99
+ ```
100
+
101
+ #### Session Modes
102
+
103
+ **Database-backed (`resolveUser`):** Cookie stores only user ID. `user()` calls `resolveUser(id)` every request. Best for real-time data freshness.
104
+
105
+ ```typescript
106
+ const auth = createAuth<User>({
107
+ secret: process.env.IDEAL_AUTH_SECRET!,
108
+ cookie: createCookieBridge(),
109
+ resolveUser: async (id) => db.user.findUnique({ where: { id } }),
110
+ hash,
111
+ resolveUserByCredentials: async (creds) =>
112
+ db.user.findUnique({ where: { email: creds.email } }),
113
+ });
114
+ ```
115
+
116
+ **Cookie-backed (`sessionFields`):** Cookie stores user ID + declared fields. `user()` reads from cookie — zero external calls. Best for performance, stateless apps, or apps without a database.
117
+
118
+ ```typescript
119
+ const auth = createAuth<User>({
120
+ secret: process.env.IDEAL_AUTH_SECRET!,
121
+ cookie: createCookieBridge(),
122
+ sessionFields: ['email', 'name', 'role'],
123
+ hash,
124
+ resolveUserByCredentials: async (creds) =>
125
+ db.user.findUnique({ where: { email: creds.email } }),
126
+ });
127
+ // user() returns { id, email, name, role } from cookie
128
+ ```
129
+
130
+ Key rules:
131
+ - Provide exactly one of `resolveUser` or `sessionFields` — both or neither throws
132
+ - `id` is always stored — don't include it in `sessionFields`
133
+ - Cookie limit is ~4KB — store basic fields only
134
+ - `sessionFields` data is a snapshot from login time — stale if user updates profile mid-session
135
+ - To refresh, call `auth().login(updatedUser)` after the update
136
+ - `loginById()` requires `resolveUser` — throws with `sessionFields`
137
+ - Both modes work with `attempt()`, `attemptUser`, `hash + resolveUserByCredentials`
138
+
139
+ #### AuthInstance Methods
140
+
141
+ | Method | Returns | Description |
142
+ |--------|---------|-------------|
143
+ | `login(user, options?)` | `Promise<void>` | Set session cookie for the given user |
144
+ | `loginById(id, options?)` | `Promise<void>` | Resolve user by ID, then set session cookie (requires `resolveUser`) |
145
+ | `attempt(credentials, options?)` | `Promise<boolean>` | Find user, verify password, login if valid |
146
+ | `logout()` | `Promise<void>` | Delete session cookie |
147
+ | `check()` | `Promise<boolean>` | Is the session valid? (fast, cached) |
148
+ | `user()` | `Promise<TUser \| null>` | Get the authenticated user (from DB with `resolveUser`, or from cookie with `sessionFields`) |
149
+ | `id()` | `Promise<string \| null>` | Get the authenticated user's ID |
150
+
151
+ #### LoginOptions
152
+
153
+ ```typescript
154
+ type LoginOptions = {
155
+ remember?: boolean;
156
+ // true: use rememberMaxAge (30 days)
157
+ // false: session cookie (expires when browser closes)
158
+ // undefined: use default maxAge (7 days)
159
+ };
160
+ ```
161
+
162
+ #### `attempt()` — Two Modes
163
+
164
+ **Laravel-style (recommended):** Provide `hash` and `resolveUserByCredentials`. The `attempt()` method strips the credential key (default `'password'`) from credentials, looks up the user with remaining fields, and verifies the hash automatically.
165
+
166
+ ```typescript
167
+ const auth = createAuth({
168
+ secret: process.env.IDEAL_AUTH_SECRET!,
169
+ cookie: createCookieBridge(),
170
+ hash,
171
+ resolveUser: async (id) => db.user.findUnique({ where: { id } }),
172
+ resolveUserByCredentials: async (creds) => {
173
+ // creds = { email: '...' } — password already stripped
174
+ return db.user.findUnique({ where: { email: creds.email } });
175
+ },
176
+ });
177
+ ```
178
+
179
+ **Manual (escape hatch):** Provide `attemptUser` for full control over lookup and verification. Takes precedence if both are provided.
180
+
181
+ ```typescript
182
+ const auth = createAuth({
183
+ secret: process.env.IDEAL_AUTH_SECRET!,
184
+ cookie: createCookieBridge(),
185
+ resolveUser: async (id) => db.user.findUnique({ where: { id } }),
186
+ attemptUser: async (creds) => {
187
+ const user = await db.user.findUnique({ where: { email: creds.email } });
188
+ if (!user) return null;
189
+ if (!(await hash.verify(creds.password, user.password))) return null;
190
+ return user;
191
+ },
192
+ });
193
+ ```
194
+
195
+ ---
196
+
197
+ ### `createHash(config?): HashInstance`
198
+
199
+ bcrypt password hashing with automatic SHA-256 prehash for passwords > 72 bytes.
200
+
201
+ ```typescript
202
+ type HashConfig = { rounds?: number }; // default: 12
203
+
204
+ type HashInstance = {
205
+ make(password: string): Promise<string>;
206
+ verify(password: string, hash: string): Promise<boolean>;
207
+ };
208
+ ```
209
+
210
+ ```typescript
211
+ import { createHash } from 'ideal-auth';
212
+
213
+ const hash = createHash({ rounds: 12 });
214
+ const hashed = await hash.make('password');
215
+ const valid = await hash.verify('password', hashed); // true
216
+ ```
217
+
218
+ ---
219
+
220
+ ### `createTokenVerifier(config): TokenVerifierInstance`
221
+
222
+ Signed, expiring tokens for password resets, email verification, magic links, invites. Create one instance per use case with its own secret/expiry.
223
+
224
+ ```typescript
225
+ type TokenVerifierConfig = {
226
+ secret: string; // 32+ chars, required
227
+ expiryMs?: number; // default: 3600000 (1 hour)
228
+ };
229
+
230
+ type TokenVerifierInstance = {
231
+ createToken(userId: string): string;
232
+ verifyToken(token: string): { userId: string; iatMs: number } | null;
233
+ };
234
+ ```
235
+
236
+ Token format: `encodedUserId.randomId.issuedAtMs.expiryMs.signature` (HMAC-SHA256 signed).
237
+
238
+ **Important:** Tokens are stateless. Use `iatMs` to reject tokens issued before a relevant event (e.g., password change). Use different secrets per use case so tokens aren't interchangeable.
239
+
240
+ ---
241
+
242
+ ### `createTOTP(config?): TOTPInstance`
243
+
244
+ RFC 6238 TOTP generation and verification.
245
+
246
+ ```typescript
247
+ type TOTPConfig = {
248
+ digits?: number; // default: 6
249
+ period?: number; // default: 30 (seconds)
250
+ window?: number; // default: 1 (±1 time step, ~90 second acceptance window)
251
+ };
252
+
253
+ type TOTPInstance = {
254
+ generateSecret(): string; // 32-char base32 string
255
+ generateQrUri(opts: { secret: string; issuer: string; account: string }): string;
256
+ verify(token: string, secret: string): boolean;
257
+ };
258
+ ```
259
+
260
+ ---
261
+
262
+ ### `generateRecoveryCodes(hash, count?): Promise<{ codes, hashed }>`
263
+
264
+ Generate backup codes for 2FA recovery. Returns plaintext codes (show once to user) and bcrypt-hashed codes (store in DB).
265
+
266
+ ```typescript
267
+ import { generateRecoveryCodes, verifyRecoveryCode, createHash } from 'ideal-auth';
268
+
269
+ const hash = createHash();
270
+ const { codes, hashed } = await generateRecoveryCodes(hash, 8);
271
+ // codes: string[] — show to user once (format: XXXXXXXX-XXXXXXXX)
272
+ // hashed: string[] — store in database
273
+
274
+ const { valid, remaining } = await verifyRecoveryCode(code, storedHashes, hash);
275
+ // valid: boolean
276
+ // remaining: string[] — update DB with this (removes used code)
277
+ ```
278
+
279
+ ---
280
+
281
+ ### `createRateLimiter(config): RateLimiterInstance`
282
+
283
+ ```typescript
284
+ type RateLimiterConfig = {
285
+ maxAttempts: number; // required
286
+ windowMs: number; // required
287
+ store?: RateLimitStore; // default: MemoryRateLimitStore
288
+ };
289
+
290
+ type RateLimitResult = {
291
+ allowed: boolean;
292
+ remaining: number;
293
+ resetAt: Date;
294
+ };
295
+
296
+ // Methods: attempt(key): Promise<RateLimitResult>, reset(key): Promise<void>
297
+ ```
298
+
299
+ **MemoryRateLimitStore** characteristics: max 10,000 entries, 1-minute cleanup interval, resets on process restart, single-process only. **Use a persistent store (Redis/DB) in production.**
300
+
301
+ Custom store interface:
302
+
303
+ ```typescript
304
+ interface RateLimitStore {
305
+ increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
306
+ reset(key: string): Promise<void>;
307
+ }
308
+ ```
309
+
310
+ ---
311
+
312
+ ### Crypto Utilities
313
+
314
+ All use `node:crypto` — no third-party dependencies.
315
+
316
+ ```typescript
317
+ import {
318
+ generateToken,
319
+ signData,
320
+ verifySignature,
321
+ encrypt,
322
+ decrypt,
323
+ timingSafeEqual,
324
+ } from 'ideal-auth';
325
+
326
+ // Random hex token (default 32 bytes = 64 hex chars)
327
+ const token = generateToken();
328
+ const short = generateToken(16); // 32 hex chars
329
+
330
+ // HMAC-SHA256 signing
331
+ const sig = signData('user:123:reset', secret);
332
+ const valid = verifySignature('user:123:reset', sig, secret);
333
+
334
+ // AES-256-GCM encryption (scrypt key derivation, base64url output)
335
+ const encrypted = await encrypt('sensitive data', secret);
336
+ const decrypted = await decrypt(encrypted, secret);
337
+
338
+ // Constant-time string comparison
339
+ timingSafeEqual('abc', 'abc'); // true
340
+ ```
341
+
342
+ ---
343
+
344
+ ### Key Types
345
+
346
+ ```typescript
347
+ type AnyUser = { id: string | number; [key: string]: any };
348
+
349
+ type CookieBridge = {
350
+ get(name: string): Promise<string | undefined> | string | undefined;
351
+ set(name: string, value: string, options: CookieOptions): Promise<void> | void;
352
+ delete(name: string): Promise<void> | void;
353
+ };
354
+
355
+ type CookieOptions = {
356
+ httpOnly?: boolean;
357
+ secure?: boolean;
358
+ sameSite?: 'lax' | 'strict' | 'none';
359
+ path?: string;
360
+ maxAge?: number;
361
+ expires?: Date;
362
+ domain?: string;
363
+ };
364
+
365
+ // httpOnly is always forced to true — not configurable
366
+ type ConfigurableCookieOptions = Omit<CookieOptions, 'httpOnly'>;
367
+
368
+ type SessionPayload = {
369
+ uid: string; // user ID (always string)
370
+ iat: number; // issued-at (Unix seconds)
371
+ exp: number; // expiration (Unix seconds)
372
+ };
373
+ ```
374
+
375
+ ---
376
+
377
+ ## Cookie Bridge Patterns
378
+
379
+ ### Next.js (App Router)
380
+
381
+ ```typescript
382
+ // lib/cookies.ts
383
+ import { cookies } from 'next/headers';
384
+ import type { CookieBridge } from 'ideal-auth';
385
+
386
+ export function createCookieBridge(): CookieBridge {
387
+ return {
388
+ async get(name: string) {
389
+ const cookieStore = await cookies();
390
+ return cookieStore.get(name)?.value;
391
+ },
392
+ async set(name, value, options) {
393
+ const cookieStore = await cookies();
394
+ cookieStore.set(name, value, options);
395
+ },
396
+ async delete(name) {
397
+ const cookieStore = await cookies();
398
+ cookieStore.delete(name);
399
+ },
400
+ };
401
+ }
402
+ ```
403
+
404
+ **IMPORTANT:** `cookies()` is async in Next.js 15+. If on Next.js 14, remove the `await`.
405
+
406
+ Auth setup:
407
+
408
+ ```typescript
409
+ // lib/auth.ts
410
+ import { createAuth, createHash } from 'ideal-auth';
411
+ import { createCookieBridge } from './cookies';
412
+ import { db } from './db';
413
+
414
+ type User = {
415
+ id: string;
416
+ email: string;
417
+ name: string;
418
+ password: string;
419
+ };
420
+
421
+ const hash = createHash({ rounds: 12 });
422
+
423
+ const auth = createAuth<User>({
424
+ secret: process.env.IDEAL_AUTH_SECRET!,
425
+ cookie: createCookieBridge(),
426
+ hash,
427
+
428
+ async resolveUser(id) {
429
+ return db.user.findUnique({ where: { id } });
430
+ },
431
+
432
+ async resolveUserByCredentials(credentials) {
433
+ return db.user.findUnique({
434
+ where: { email: credentials.email },
435
+ });
436
+ },
437
+ });
438
+
439
+ export { auth, hash };
440
+ ```
441
+
442
+ **`createAuth` returns a factory function.** Call `auth()` inside each Server Action. Do not call at module level.
443
+
444
+ Login action:
445
+
446
+ ```typescript
447
+ // app/actions/login.ts
448
+ 'use server';
449
+
450
+ import { redirect } from 'next/navigation';
451
+ import { auth } from '@/lib/auth';
452
+
453
+ export async function loginAction(_prev: unknown, formData: FormData) {
454
+ const email = formData.get('email') as string;
455
+ const password = formData.get('password') as string;
456
+ const remember = formData.get('remember') === 'on';
457
+
458
+ if (!email || !password) {
459
+ return { error: 'Email and password are required.' };
460
+ }
461
+
462
+ const session = auth();
463
+ const success = await session.attempt(
464
+ { email, password },
465
+ { remember },
466
+ );
467
+
468
+ if (!success) {
469
+ return { error: 'Invalid email or password.' };
470
+ }
471
+
472
+ redirect('/dashboard');
473
+ }
474
+ ```
475
+
476
+ Login form:
477
+
478
+ ```tsx
479
+ // app/login/page.tsx
480
+ 'use client';
481
+
482
+ import { useActionState } from 'react';
483
+ import { loginAction } from '@/app/actions/login';
484
+
485
+ export default function LoginPage() {
486
+ const [state, formAction, pending] = useActionState(loginAction, null);
487
+
488
+ return (
489
+ <form action={formAction}>
490
+ {state?.error && <p className="text-red-500">{state.error}</p>}
491
+
492
+ <label htmlFor="email">Email</label>
493
+ <input id="email" name="email" type="email" required />
494
+
495
+ <label htmlFor="password">Password</label>
496
+ <input id="password" name="password" type="password" required />
497
+
498
+ <label>
499
+ <input name="remember" type="checkbox" /> Remember me
500
+ </label>
501
+
502
+ <button type="submit" disabled={pending}>
503
+ {pending ? 'Signing in...' : 'Sign in'}
504
+ </button>
505
+ </form>
506
+ );
507
+ }
508
+ ```
509
+
510
+ Registration action:
511
+
512
+ ```typescript
513
+ // app/actions/register.ts
514
+ 'use server';
515
+
516
+ import { redirect } from 'next/navigation';
517
+ import { auth, hash } from '@/lib/auth';
518
+ import { db } from '@/lib/db';
519
+
520
+ export async function registerAction(_prev: unknown, formData: FormData) {
521
+ const email = formData.get('email') as string;
522
+ const name = formData.get('name') as string;
523
+ const password = formData.get('password') as string;
524
+ const passwordConfirmation = formData.get('password_confirmation') as string;
525
+
526
+ if (!email || !name || !password) {
527
+ return { error: 'All fields are required.' };
528
+ }
529
+
530
+ if (password.length < 8) {
531
+ return { error: 'Password must be at least 8 characters.' };
532
+ }
533
+
534
+ if (password !== passwordConfirmation) {
535
+ return { error: 'Passwords do not match.' };
536
+ }
537
+
538
+ const existing = await db.user.findUnique({ where: { email } });
539
+ if (existing) {
540
+ return { error: 'An account with this email already exists.' };
541
+ }
542
+
543
+ const user = await db.user.create({
544
+ data: {
545
+ email,
546
+ name,
547
+ password: await hash.make(password),
548
+ },
549
+ });
550
+
551
+ const session = auth();
552
+ await session.login(user);
553
+
554
+ redirect('/dashboard');
555
+ }
556
+ ```
557
+
558
+ Logout action:
559
+
560
+ ```typescript
561
+ // app/actions/logout.ts
562
+ 'use server';
563
+
564
+ import { redirect } from 'next/navigation';
565
+ import { auth } from '@/lib/auth';
566
+
567
+ export async function logoutAction() {
568
+ const session = auth();
569
+ await session.logout();
570
+ redirect('/login');
571
+ }
572
+ ```
573
+
574
+ Middleware (route protection):
575
+
576
+ ```typescript
577
+ // middleware.ts
578
+ import { NextResponse } from 'next/server';
579
+ import type { NextRequest } from 'next/server';
580
+
581
+ const protectedRoutes = ['/dashboard', '/settings', '/profile'];
582
+ const authRoutes = ['/login', '/register'];
583
+
584
+ export function middleware(request: NextRequest) {
585
+ const { pathname } = request.nextUrl;
586
+ const hasSession = request.cookies.has('ideal_session');
587
+
588
+ if (protectedRoutes.some((route) => pathname.startsWith(route))) {
589
+ if (!hasSession) {
590
+ const loginUrl = new URL('/login', request.url);
591
+ loginUrl.searchParams.set('callbackUrl', pathname);
592
+ return NextResponse.redirect(loginUrl);
593
+ }
594
+ }
595
+
596
+ if (authRoutes.some((route) => pathname.startsWith(route))) {
597
+ if (hasSession) {
598
+ return NextResponse.redirect(new URL('/dashboard', request.url));
599
+ }
600
+ }
601
+
602
+ return NextResponse.next();
603
+ }
604
+
605
+ export const config = {
606
+ matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*', '/login', '/register'],
607
+ };
608
+ ```
609
+
610
+ **Note:** Next.js middleware runs on Edge Runtime. `auth()` requires Node.js runtime (iron-session uses Node crypto). The middleware checks cookie existence as a fast first pass; actual cryptographic verification happens server-side via `auth().check()` or `auth().user()`.
611
+
612
+ Server-side auth guard helper:
613
+
614
+ ```typescript
615
+ // lib/auth-guard.ts
616
+ import { redirect } from 'next/navigation';
617
+ import { auth } from '@/lib/auth';
618
+
619
+ export async function requireAuth() {
620
+ const session = auth();
621
+ const user = await session.user();
622
+
623
+ if (!user) {
624
+ redirect('/login');
625
+ }
626
+
627
+ return user;
628
+ }
629
+ ```
630
+
631
+ Getting the current user in a Server Component:
632
+
633
+ ```tsx
634
+ // app/dashboard/page.tsx
635
+ import { auth } from '@/lib/auth';
636
+ import { redirect } from 'next/navigation';
637
+
638
+ export default async function DashboardPage() {
639
+ const session = auth();
640
+ const user = await session.user();
641
+
642
+ if (!user) redirect('/login');
643
+
644
+ return <h1>Welcome, {user.name}</h1>;
645
+ }
646
+ ```
647
+
648
+ Pass user data to Client Components as props — only pass serializable, non-sensitive fields.
649
+
650
+ **CSRF:** Next.js Server Actions have built-in CSRF protection (Origin header validation). For API Route Handlers, validate the Origin header manually:
651
+
652
+ ```typescript
653
+ // app/api/example/route.ts
654
+ import { headers } from 'next/headers';
655
+ import { NextResponse } from 'next/server';
656
+
657
+ export async function POST(request: Request) {
658
+ const headerStore = await headers();
659
+ const origin = headerStore.get('origin');
660
+ const host = headerStore.get('host');
661
+
662
+ if (!origin || new URL(origin).host !== host) {
663
+ return NextResponse.json({ error: 'Invalid origin' }, { status: 403 });
664
+ }
665
+
666
+ // ... handle request
667
+ }
668
+ ```
669
+
670
+ **Edge Runtime:** Use `export const runtime = 'nodejs'` in any Route Handler that calls `auth()`.
671
+
672
+ ---
673
+
674
+ ### SvelteKit
675
+
676
+ ```typescript
677
+ // src/lib/server/cookies.ts
678
+ import type { Cookies } from '@sveltejs/kit';
679
+ import type { CookieBridge } from 'ideal-auth';
680
+
681
+ export function createCookieBridge(cookies: Cookies): CookieBridge {
682
+ return {
683
+ get(name: string) {
684
+ return cookies.get(name);
685
+ },
686
+ set(name, value, options) {
687
+ cookies.set(name, value, {
688
+ ...options,
689
+ path: options.path ?? '/',
690
+ });
691
+ },
692
+ delete(name) {
693
+ cookies.delete(name, { path: '/' });
694
+ },
695
+ };
696
+ }
697
+ ```
698
+
699
+ SvelteKit requires an explicit `path` on `cookies.set()`.
700
+
701
+ Auth setup:
702
+
703
+ ```typescript
704
+ // src/lib/server/auth.ts
705
+ import { createAuth, createHash } from 'ideal-auth';
706
+ import { createCookieBridge } from './cookies';
707
+ import { db } from '$lib/server/db';
708
+ import { IDEAL_AUTH_SECRET } from '$env/static/private';
709
+
710
+ type User = { id: string; email: string; name: string; password: string };
711
+
712
+ export const hash = createHash({ rounds: 12 });
713
+
714
+ export function auth(cookies: import('@sveltejs/kit').Cookies) {
715
+ const authFactory = createAuth<User>({
716
+ secret: IDEAL_AUTH_SECRET,
717
+ cookie: createCookieBridge(cookies),
718
+ hash,
719
+
720
+ async resolveUser(id) {
721
+ return db.user.findUnique({ where: { id } });
722
+ },
723
+
724
+ async resolveUserByCredentials(credentials) {
725
+ return db.user.findUnique({ where: { email: credentials.email } });
726
+ },
727
+ });
728
+
729
+ return authFactory();
730
+ }
731
+ ```
732
+
733
+ Pass `cookies` from the `RequestEvent` each time — keeps requests isolated.
734
+
735
+ Login (form action):
736
+
737
+ ```typescript
738
+ // src/routes/login/+page.server.ts
739
+ import { fail, redirect } from '@sveltejs/kit';
740
+ import { auth } from '$lib/server/auth';
741
+ import type { Actions, PageServerLoad } from './$types';
742
+
743
+ export const load: PageServerLoad = async ({ cookies }) => {
744
+ const session = auth(cookies);
745
+ if (await session.check()) redirect(303, '/dashboard');
746
+ };
747
+
748
+ export const actions: Actions = {
749
+ default: async ({ request, cookies }) => {
750
+ const data = await request.formData();
751
+ const email = data.get('email') as string;
752
+ const password = data.get('password') as string;
753
+ const remember = data.get('remember') === 'on';
754
+
755
+ if (!email || !password) {
756
+ return fail(400, { error: 'Email and password are required.', email });
757
+ }
758
+
759
+ const session = auth(cookies);
760
+ const success = await session.attempt({ email, password }, { remember });
761
+
762
+ if (!success) {
763
+ return fail(400, { error: 'Invalid email or password.', email });
764
+ }
765
+
766
+ redirect(303, '/dashboard');
767
+ },
768
+ };
769
+ ```
770
+
771
+ Registration (form action):
772
+
773
+ ```typescript
774
+ // src/routes/register/+page.server.ts
775
+ import { fail, redirect } from '@sveltejs/kit';
776
+ import { auth, hash } from '$lib/server/auth';
777
+ import { db } from '$lib/server/db';
778
+ import type { Actions } from './$types';
779
+
780
+ export const actions: Actions = {
781
+ default: async ({ request, cookies }) => {
782
+ const data = await request.formData();
783
+ const email = data.get('email') as string;
784
+ const name = data.get('name') as string;
785
+ const password = data.get('password') as string;
786
+ const passwordConfirmation = data.get('password_confirmation') as string;
787
+
788
+ if (!email || !name || !password) {
789
+ return fail(400, { error: 'All fields are required.', email, name });
790
+ }
791
+
792
+ if (password.length < 8) {
793
+ return fail(400, { error: 'Password must be at least 8 characters.', email, name });
794
+ }
795
+
796
+ if (password !== passwordConfirmation) {
797
+ return fail(400, { error: 'Passwords do not match.', email, name });
798
+ }
799
+
800
+ const existing = await db.user.findUnique({ where: { email } });
801
+ if (existing) {
802
+ return fail(400, { error: 'An account with this email already exists.', email, name });
803
+ }
804
+
805
+ const user = await db.user.create({
806
+ data: { email, name, password: await hash.make(password) },
807
+ });
808
+
809
+ const session = auth(cookies);
810
+ await session.login(user);
811
+
812
+ redirect(303, '/dashboard');
813
+ },
814
+ };
815
+ ```
816
+
817
+ Auth guard (handle hook):
818
+
819
+ ```typescript
820
+ // src/hooks.server.ts
821
+ import { redirect, type Handle } from '@sveltejs/kit';
822
+ import { auth } from '$lib/server/auth';
823
+
824
+ const protectedRoutes = ['/dashboard', '/settings', '/profile'];
825
+ const authRoutes = ['/login', '/register'];
826
+
827
+ export const handle: Handle = async ({ event, resolve }) => {
828
+ const session = auth(event.cookies);
829
+ const user = await session.user();
830
+
831
+ event.locals.user = user
832
+ ? { id: user.id, email: user.email, name: user.name }
833
+ : null;
834
+
835
+ const { pathname } = event.url;
836
+
837
+ if (protectedRoutes.some((route) => pathname.startsWith(route))) {
838
+ if (!user) redirect(303, `/login?callbackUrl=${encodeURIComponent(pathname)}`);
839
+ }
840
+
841
+ if (authRoutes.some((route) => pathname.startsWith(route))) {
842
+ if (user) redirect(303, '/dashboard');
843
+ }
844
+
845
+ return resolve(event);
846
+ };
847
+ ```
848
+
849
+ Declare types in `src/app.d.ts`:
850
+
851
+ ```typescript
852
+ declare global {
853
+ namespace App {
854
+ interface Locals {
855
+ user: { id: string; email: string; name: string } | null;
856
+ }
857
+ }
858
+ }
859
+
860
+ export {};
861
+ ```
862
+
863
+ **CSRF:** SvelteKit has built-in CSRF protection — auto-validates Origin header on all form submissions. Do not set `checkOrigin: false` in production.
864
+
865
+ ---
866
+
867
+ ### Express
868
+
869
+ Requires `cookie-parser`: `bun add cookie-parser` + `bun add -D @types/cookie-parser @types/express`
870
+
871
+ ```typescript
872
+ // src/lib/cookies.ts
873
+ import type { Request, Response } from 'express';
874
+ import type { CookieBridge } from 'ideal-auth';
875
+
876
+ export function createCookieBridge(req: Request, res: Response): CookieBridge {
877
+ return {
878
+ get(name: string) {
879
+ return req.cookies[name];
880
+ },
881
+ set(name, value, options) {
882
+ res.cookie(name, value, {
883
+ httpOnly: options.httpOnly,
884
+ secure: options.secure,
885
+ sameSite: options.sameSite,
886
+ path: options.path ?? '/',
887
+ ...(options.maxAge !== undefined && { maxAge: options.maxAge * 1000 }),
888
+ });
889
+ },
890
+ delete(name) {
891
+ res.clearCookie(name, { path: '/' });
892
+ },
893
+ };
894
+ }
895
+ ```
896
+
897
+ **IMPORTANT:** Express `res.cookie()` uses milliseconds for `maxAge`, but ideal-auth provides seconds. The bridge multiplies by 1000.
898
+
899
+ Auth setup:
900
+
901
+ ```typescript
902
+ // src/lib/auth.ts
903
+ import { createAuth, createHash } from 'ideal-auth';
904
+ import { createCookieBridge } from './cookies';
905
+ import { db } from './db';
906
+
907
+ type User = { id: string; email: string; name: string; password: string };
908
+
909
+ export const hash = createHash({ rounds: 12 });
910
+
911
+ export function auth(req: import('express').Request, res: import('express').Response) {
912
+ const authFactory = createAuth<User>({
913
+ secret: process.env.IDEAL_AUTH_SECRET!,
914
+ cookie: createCookieBridge(req, res),
915
+ hash,
916
+
917
+ async resolveUser(id) {
918
+ return db.user.findUnique({ where: { id } });
919
+ },
920
+
921
+ async resolveUserByCredentials(credentials) {
922
+ return db.user.findUnique({ where: { email: credentials.email } });
923
+ },
924
+ });
925
+
926
+ return authFactory();
927
+ }
928
+ ```
929
+
930
+ App setup:
931
+
932
+ ```typescript
933
+ // src/app.ts
934
+ import express from 'express';
935
+ import cookieParser from 'cookie-parser';
936
+
937
+ const app = express();
938
+ app.use(express.json());
939
+ app.use(express.urlencoded({ extended: true }));
940
+ app.use(cookieParser());
941
+ app.use(csrfProtection); // see CSRF section below
942
+ ```
943
+
944
+ Auth middleware:
945
+
946
+ ```typescript
947
+ // src/middleware/auth.ts
948
+ import type { Request, Response, NextFunction } from 'express';
949
+ import { auth } from '../lib/auth';
950
+
951
+ export async function requireAuth(req: Request, res: Response, next: NextFunction) {
952
+ const session = auth(req, res);
953
+ const user = await session.user();
954
+
955
+ if (!user) {
956
+ return res.status(401).json({ error: 'Authentication required.' });
957
+ }
958
+
959
+ req.user = user;
960
+ next();
961
+ }
962
+ ```
963
+
964
+ Extend Express Request type:
965
+
966
+ ```typescript
967
+ // src/types/express.d.ts
968
+ declare global {
969
+ namespace Express {
970
+ interface Request {
971
+ user?: { id: string; email: string; name: string };
972
+ }
973
+ }
974
+ }
975
+
976
+ export {};
977
+ ```
978
+
979
+ **CSRF:** Express has NO built-in CSRF protection. Implement Origin header validation:
980
+
981
+ ```typescript
982
+ // src/middleware/csrf.ts
983
+ import type { Request, Response, NextFunction } from 'express';
984
+
985
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
986
+
987
+ export function csrfProtection(req: Request, res: Response, next: NextFunction) {
988
+ if (SAFE_METHODS.includes(req.method)) return next();
989
+
990
+ const origin = req.get('origin');
991
+ const host = req.get('host');
992
+
993
+ if (!origin || !host) {
994
+ return res.status(403).json({ error: 'Forbidden: missing origin header.' });
995
+ }
996
+
997
+ try {
998
+ if (new URL(origin).host !== host) {
999
+ return res.status(403).json({ error: 'Forbidden: origin mismatch.' });
1000
+ }
1001
+ } catch {
1002
+ return res.status(403).json({ error: 'Forbidden: invalid origin.' });
1003
+ }
1004
+
1005
+ next();
1006
+ }
1007
+ ```
1008
+
1009
+ For HTML forms, use a CSRF token approach with `generateToken` and `timingSafeEqual` from ideal-auth.
1010
+
1011
+ ---
1012
+
1013
+ ### Hono
1014
+
1015
+ ```typescript
1016
+ // src/lib/cookies.ts
1017
+ import type { Context } from 'hono';
1018
+ import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
1019
+ import type { CookieBridge } from 'ideal-auth';
1020
+
1021
+ export function createCookieBridge(c: Context): CookieBridge {
1022
+ return {
1023
+ get(name: string) {
1024
+ return getCookie(c, name);
1025
+ },
1026
+ set(name, value, options) {
1027
+ setCookie(c, name, value, {
1028
+ httpOnly: options.httpOnly,
1029
+ secure: options.secure,
1030
+ sameSite: options.sameSite === 'lax' ? 'Lax' : options.sameSite === 'strict' ? 'Strict' : 'None',
1031
+ path: options.path ?? '/',
1032
+ ...(options.maxAge !== undefined && { maxAge: options.maxAge }),
1033
+ });
1034
+ },
1035
+ delete(name) {
1036
+ deleteCookie(c, name, { path: '/' });
1037
+ },
1038
+ };
1039
+ }
1040
+ ```
1041
+
1042
+ **Note:** Hono expects capitalized `sameSite` values (`'Lax'`, `'Strict'`, `'None'`). The bridge converts.
1043
+
1044
+ Auth setup:
1045
+
1046
+ ```typescript
1047
+ // src/lib/auth.ts
1048
+ import { createAuth, createHash } from 'ideal-auth';
1049
+ import { createCookieBridge } from './cookies';
1050
+ import { db } from './db';
1051
+
1052
+ type User = { id: string; email: string; name: string; password: string };
1053
+
1054
+ export const hash = createHash({ rounds: 12 });
1055
+
1056
+ export function auth(c: import('hono').Context) {
1057
+ const authFactory = createAuth<User>({
1058
+ secret: c.env?.IDEAL_AUTH_SECRET ?? process.env.IDEAL_AUTH_SECRET!,
1059
+ cookie: createCookieBridge(c),
1060
+ hash,
1061
+
1062
+ async resolveUser(id) {
1063
+ return db.user.findUnique({ where: { id } });
1064
+ },
1065
+
1066
+ async resolveUserByCredentials(credentials) {
1067
+ return db.user.findUnique({ where: { email: credentials.email } });
1068
+ },
1069
+ });
1070
+
1071
+ return authFactory();
1072
+ }
1073
+ ```
1074
+
1075
+ Auth middleware:
1076
+
1077
+ ```typescript
1078
+ // src/middleware/auth.ts
1079
+ import { createMiddleware } from 'hono/factory';
1080
+ import { auth } from '../lib/auth';
1081
+
1082
+ type Env = {
1083
+ Variables: { user: { id: string; email: string; name: string } };
1084
+ };
1085
+
1086
+ export const requireAuth = createMiddleware<Env>(async (c, next) => {
1087
+ const session = auth(c);
1088
+ const user = await session.user();
1089
+
1090
+ if (!user) return c.json({ error: 'Authentication required.' }, 401);
1091
+
1092
+ c.set('user', { id: user.id, email: user.email, name: user.name });
1093
+ await next();
1094
+ });
1095
+ ```
1096
+
1097
+ **CSRF:** Hono has built-in `csrf()` middleware:
1098
+
1099
+ ```typescript
1100
+ import { csrf } from 'hono/csrf';
1101
+ app.use(csrf());
1102
+ ```
1103
+
1104
+ **Cloudflare Workers:** Add `nodejs_compat` compatibility flag to `wrangler.toml`. Access env via `c.env.IDEAL_AUTH_SECRET`.
1105
+
1106
+ ---
1107
+
1108
+ ### Nuxt
1109
+
1110
+ ```typescript
1111
+ // server/utils/cookies.ts
1112
+ import type { H3Event } from 'h3';
1113
+ import type { CookieBridge } from 'ideal-auth';
1114
+
1115
+ export function createCookieBridge(event: H3Event): CookieBridge {
1116
+ return {
1117
+ get(name: string) {
1118
+ return getCookie(event, name);
1119
+ },
1120
+ set(name, value, options) {
1121
+ setCookie(event, name, value, options);
1122
+ },
1123
+ delete(name) {
1124
+ deleteCookie(event, name, { path: '/' });
1125
+ },
1126
+ };
1127
+ }
1128
+ ```
1129
+
1130
+ `getCookie`, `setCookie`, `deleteCookie` are auto-imported from `h3` in Nuxt server routes.
1131
+
1132
+ Auth setup:
1133
+
1134
+ ```typescript
1135
+ // server/utils/auth.ts
1136
+ import { createAuth, createHash } from 'ideal-auth';
1137
+ import { createCookieBridge } from './cookies';
1138
+
1139
+ type User = { id: string; email: string; name: string; password: string };
1140
+
1141
+ export const hash = createHash({ rounds: 12 });
1142
+
1143
+ export function auth(event: H3Event) {
1144
+ const config = useRuntimeConfig();
1145
+
1146
+ const authFactory = createAuth<User>({
1147
+ secret: config.idealAuthSecret,
1148
+ cookie: createCookieBridge(event),
1149
+ hash,
1150
+
1151
+ async resolveUser(id) {
1152
+ return db.user.findUnique({ where: { id } });
1153
+ },
1154
+
1155
+ async resolveUserByCredentials(credentials) {
1156
+ return db.user.findUnique({ where: { email: credentials.email } });
1157
+ },
1158
+ });
1159
+
1160
+ return authFactory();
1161
+ }
1162
+ ```
1163
+
1164
+ Register the secret in `nuxt.config.ts`:
1165
+
1166
+ ```typescript
1167
+ export default defineNuxtConfig({
1168
+ runtimeConfig: {
1169
+ idealAuthSecret: process.env.IDEAL_AUTH_SECRET,
1170
+ },
1171
+ });
1172
+ ```
1173
+
1174
+ Files in `server/utils/` are auto-imported. Call `auth(event)` directly.
1175
+
1176
+ Login API route:
1177
+
1178
+ ```typescript
1179
+ // server/api/auth/login.post.ts
1180
+ export default defineEventHandler(async (event) => {
1181
+ const body = await readBody(event);
1182
+
1183
+ if (!body.email || !body.password) {
1184
+ throw createError({ statusCode: 400, statusMessage: 'Email and password are required.' });
1185
+ }
1186
+
1187
+ const session = auth(event);
1188
+ const success = await session.attempt(
1189
+ { email: body.email, password: body.password },
1190
+ { remember: body.remember ?? false },
1191
+ );
1192
+
1193
+ if (!success) {
1194
+ throw createError({ statusCode: 401, statusMessage: 'Invalid email or password.' });
1195
+ }
1196
+
1197
+ return { success: true };
1198
+ });
1199
+ ```
1200
+
1201
+ **CSRF:** Nuxt has NO built-in CSRF protection. Implement Origin header validation as server middleware, or use the `nuxt-security` module.
1202
+
1203
+ ---
1204
+
1205
+ ### TanStack Start
1206
+
1207
+ ```typescript
1208
+ // app/lib/cookies.ts
1209
+ import { getCookie, setCookie, deleteCookie } from 'vinxi/http';
1210
+ import type { CookieBridge } from 'ideal-auth';
1211
+
1212
+ export function createCookieBridge(): CookieBridge {
1213
+ return {
1214
+ get(name: string) {
1215
+ return getCookie(name);
1216
+ },
1217
+ set(name, value, options) {
1218
+ setCookie(name, value, options);
1219
+ },
1220
+ delete(name) {
1221
+ deleteCookie(name, { path: '/' });
1222
+ },
1223
+ };
1224
+ }
1225
+ ```
1226
+
1227
+ **IMPORTANT:** `vinxi/http` cookie functions use async local storage — must be called within a `createServerFn` handler or server middleware.
1228
+
1229
+ Auth setup:
1230
+
1231
+ ```typescript
1232
+ // app/lib/auth.ts
1233
+ import { createAuth, createHash } from 'ideal-auth';
1234
+ import { createCookieBridge } from './cookies';
1235
+ import { db } from './db';
1236
+
1237
+ type User = { id: string; email: string; name: string; password: string };
1238
+
1239
+ export const hash = createHash({ rounds: 12 });
1240
+
1241
+ const authFactory = createAuth<User>({
1242
+ secret: process.env.IDEAL_AUTH_SECRET!,
1243
+ cookie: createCookieBridge(),
1244
+ hash,
1245
+
1246
+ async resolveUser(id) {
1247
+ return db.user.findUnique({ where: { id } });
1248
+ },
1249
+
1250
+ async resolveUserByCredentials(credentials) {
1251
+ return db.user.findUnique({ where: { email: credentials.email } });
1252
+ },
1253
+ });
1254
+
1255
+ export function auth() {
1256
+ return authFactory();
1257
+ }
1258
+ ```
1259
+
1260
+ Server functions:
1261
+
1262
+ ```typescript
1263
+ // app/lib/auth.actions.ts
1264
+ import { createServerFn } from '@tanstack/start';
1265
+ import { auth, hash } from './auth';
1266
+ import { db } from './db';
1267
+
1268
+ export const loginFn = createServerFn({ method: 'POST' })
1269
+ .validator((data: { email: string; password: string; remember?: boolean }) => data)
1270
+ .handler(async ({ data }) => {
1271
+ if (!data.email || !data.password) throw new Error('Email and password are required.');
1272
+
1273
+ const session = auth();
1274
+ const success = await session.attempt(
1275
+ { email: data.email, password: data.password },
1276
+ { remember: data.remember ?? false },
1277
+ );
1278
+
1279
+ if (!success) throw new Error('Invalid email or password.');
1280
+
1281
+ return { success: true };
1282
+ });
1283
+
1284
+ export const getCurrentUserFn = createServerFn({ method: 'GET' })
1285
+ .handler(async () => {
1286
+ const session = auth();
1287
+ const user = await session.user();
1288
+
1289
+ if (!user) return { user: null };
1290
+
1291
+ return { user: { id: user.id, email: user.email, name: user.name } };
1292
+ });
1293
+ ```
1294
+
1295
+ Route protection with `beforeLoad`:
1296
+
1297
+ ```tsx
1298
+ // app/routes/dashboard.tsx
1299
+ import { createFileRoute, redirect } from '@tanstack/react-router';
1300
+ import { getCurrentUserFn } from '../lib/auth.actions';
1301
+
1302
+ export const Route = createFileRoute('/dashboard')({
1303
+ beforeLoad: async () => {
1304
+ const { user } = await getCurrentUserFn();
1305
+
1306
+ if (!user) {
1307
+ throw redirect({ to: '/login', search: { callbackUrl: '/dashboard' } });
1308
+ }
1309
+
1310
+ return { user };
1311
+ },
1312
+ component: DashboardPage,
1313
+ });
1314
+
1315
+ function DashboardPage() {
1316
+ const { user } = Route.useRouteContext();
1317
+ return <h1>Welcome, {user.name}</h1>;
1318
+ }
1319
+ ```
1320
+
1321
+ **CSRF:** TanStack Start has NO built-in CSRF protection. Validate the Origin header manually.
1322
+
1323
+ ---
1324
+
1325
+ ### Elysia
1326
+
1327
+ ```typescript
1328
+ // src/lib/auth.ts
1329
+ import { createAuth, createHash } from 'ideal-auth';
1330
+ import type { Context } from 'elysia';
1331
+ import { db } from './db';
1332
+
1333
+ export const hash = createHash({ rounds: 12 });
1334
+
1335
+ export function auth(ctx: Context) {
1336
+ const { cookie } = ctx;
1337
+
1338
+ return createAuth({
1339
+ secret: process.env.IDEAL_AUTH_SECRET!,
1340
+
1341
+ cookie: {
1342
+ get: (name) => cookie[name]?.value,
1343
+ set: (name, value, opts) => {
1344
+ cookie[name].set({
1345
+ value,
1346
+ httpOnly: opts.httpOnly,
1347
+ secure: opts.secure,
1348
+ sameSite: opts.sameSite,
1349
+ path: opts.path,
1350
+ maxAge: opts.maxAge,
1351
+ });
1352
+ },
1353
+ delete: (name) => cookie[name].remove(),
1354
+ },
1355
+
1356
+ hash,
1357
+
1358
+ resolveUser: async (id) => db.user.findUnique({ where: { id } }),
1359
+
1360
+ resolveUserByCredentials: async (creds) =>
1361
+ db.user.findUnique({ where: { email: creds.email } }),
1362
+ });
1363
+ }
1364
+ ```
1365
+
1366
+ **Note:** `auth(ctx)` returns the factory. Call `auth(ctx)()` to get the instance.
1367
+
1368
+ Auth middleware with `derive`:
1369
+
1370
+ ```typescript
1371
+ // src/middleware/auth.ts
1372
+ import { Elysia } from 'elysia';
1373
+ import { auth } from '../lib/auth';
1374
+
1375
+ export const requireAuth = new Elysia({ name: 'requireAuth' })
1376
+ .derive(async (ctx) => {
1377
+ const session = auth(ctx)();
1378
+ const user = await session.user();
1379
+
1380
+ if (!user) {
1381
+ ctx.set.status = 401;
1382
+ throw new Error('Unauthorized');
1383
+ }
1384
+
1385
+ return { user };
1386
+ });
1387
+ ```
1388
+
1389
+ **CSRF:** Elysia has no built-in CSRF. Validate Origin header via `onBeforeHandle`.
1390
+
1391
+ ---
1392
+
1393
+ ## Common Auth Flows
1394
+
1395
+ ### Password Reset
1396
+
1397
+ ```typescript
1398
+ import { createTokenVerifier, createHash } from 'ideal-auth';
1399
+
1400
+ const passwordReset = createTokenVerifier({
1401
+ secret: process.env.IDEAL_AUTH_SECRET! + '-reset', // use a different secret per use case
1402
+ expiryMs: 60 * 60 * 1000, // 1 hour
1403
+ });
1404
+
1405
+ // Step 1: User requests reset
1406
+ const token = passwordReset.createToken(user.id);
1407
+ await sendEmail(user.email, `https://app.com/reset/${token}`);
1408
+ // Put token in URL path, NOT query string (query strings are logged)
1409
+
1410
+ // Step 2: User clicks link
1411
+ const result = passwordReset.verifyToken(token);
1412
+ if (!result) throw new Error('Invalid or expired token');
1413
+
1414
+ // Step 3: Validate token hasn't been used (CRITICAL — tokens are stateless)
1415
+ if (result.iatMs < user.passwordChangedAt) {
1416
+ throw new Error('Token already used');
1417
+ }
1418
+
1419
+ // Step 4: Update password
1420
+ const hash = createHash();
1421
+ await db.user.update({
1422
+ where: { id: result.userId },
1423
+ data: {
1424
+ password: await hash.make(newPassword),
1425
+ passwordChangedAt: Date.now(),
1426
+ },
1427
+ });
1428
+ ```
1429
+
1430
+ ### Email Verification
1431
+
1432
+ ```typescript
1433
+ const emailVerification = createTokenVerifier({
1434
+ secret: process.env.IDEAL_AUTH_SECRET! + '-email',
1435
+ expiryMs: 24 * 60 * 60 * 1000, // 24 hours
1436
+ });
1437
+
1438
+ // After registration
1439
+ const token = emailVerification.createToken(user.id);
1440
+ await sendEmail(user.email, `https://app.com/verify/${token}`);
1441
+
1442
+ // Verify
1443
+ const result = emailVerification.verifyToken(token);
1444
+ if (!result) throw new Error('Invalid or expired token');
1445
+
1446
+ await db.user.update({
1447
+ where: { id: result.userId },
1448
+ data: { emailVerifiedAt: new Date() },
1449
+ });
1450
+ ```
1451
+
1452
+ ### Two-Factor Authentication (TOTP)
1453
+
1454
+ **Setup phase:**
1455
+
1456
+ ```typescript
1457
+ import { createTOTP, createHash, encrypt, generateRecoveryCodes } from 'ideal-auth';
1458
+
1459
+ const totp = createTOTP();
1460
+ const hash = createHash();
1461
+
1462
+ // 1. Generate secret
1463
+ const secret = totp.generateSecret();
1464
+
1465
+ // 2. Create QR code URI
1466
+ const uri = totp.generateQrUri({
1467
+ secret,
1468
+ issuer: 'MyApp',
1469
+ account: user.email,
1470
+ });
1471
+ // Render uri as QR code with any QR library
1472
+
1473
+ // 3. Verify user can produce a valid code
1474
+ if (!totp.verify(codeFromAuthenticator, secret)) {
1475
+ throw new Error('Invalid setup code');
1476
+ }
1477
+
1478
+ // 4. Store secret encrypted
1479
+ await db.user.update({
1480
+ where: { id: user.id },
1481
+ data: {
1482
+ totpSecret: await encrypt(secret, process.env.ENCRYPTION_KEY!),
1483
+ totpEnabled: true,
1484
+ },
1485
+ });
1486
+
1487
+ // 5. Generate recovery codes
1488
+ const { codes, hashed } = await generateRecoveryCodes(hash, 8);
1489
+ // Show codes to user ONCE, store hashed in DB
1490
+ await db.user.update({
1491
+ where: { id: user.id },
1492
+ data: { recoveryCodes: hashed },
1493
+ });
1494
+ ```
1495
+
1496
+ **Login with 2FA:**
1497
+
1498
+ ```typescript
1499
+ import { decrypt } from 'ideal-auth';
1500
+
1501
+ // After password verification, check if 2FA is enabled
1502
+ if (user.totpEnabled) {
1503
+ const decryptedSecret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY!);
1504
+
1505
+ if (!totp.verify(codeFromUser, decryptedSecret)) {
1506
+ throw new Error('Invalid 2FA code');
1507
+ }
1508
+ }
1509
+
1510
+ await auth().login(user);
1511
+ ```
1512
+
1513
+ **Recovery code login:**
1514
+
1515
+ ```typescript
1516
+ import { verifyRecoveryCode } from 'ideal-auth';
1517
+
1518
+ const { valid, remaining } = await verifyRecoveryCode(code, user.recoveryCodes, hash);
1519
+ if (valid) {
1520
+ await db.user.update({
1521
+ where: { id: user.id },
1522
+ data: { recoveryCodes: remaining },
1523
+ });
1524
+ await auth().login(user);
1525
+ }
1526
+ ```
1527
+
1528
+ ### Rate-Limited Login (Next.js example)
1529
+
1530
+ ```typescript
1531
+ 'use server';
1532
+
1533
+ import { redirect } from 'next/navigation';
1534
+ import { headers } from 'next/headers';
1535
+ import { auth } from '@/lib/auth';
1536
+ import { createRateLimiter } from 'ideal-auth';
1537
+
1538
+ const limiter = createRateLimiter({
1539
+ maxAttempts: 5,
1540
+ windowMs: 60_000, // 1 minute
1541
+ });
1542
+
1543
+ export async function loginAction(formData: FormData) {
1544
+ const email = formData.get('email') as string;
1545
+ const password = formData.get('password') as string;
1546
+
1547
+ const headerStore = await headers();
1548
+ const ip = headerStore.get('x-forwarded-for') ?? '127.0.0.1';
1549
+ const key = `login:${ip}`;
1550
+
1551
+ const { allowed, remaining, resetAt } = await limiter.attempt(key);
1552
+
1553
+ if (!allowed) {
1554
+ const seconds = Math.ceil((resetAt.getTime() - Date.now()) / 1000);
1555
+ redirect(`/?error=rate_limit&retry=${seconds}`);
1556
+ }
1557
+
1558
+ const session = auth();
1559
+ const success = await session.attempt({ email, password });
1560
+
1561
+ if (!success) {
1562
+ redirect(`/?error=invalid&remaining=${remaining}`);
1563
+ }
1564
+
1565
+ await limiter.reset(key);
1566
+ redirect('/');
1567
+ }
1568
+ ```
1569
+
1570
+ ### Remember Me
1571
+
1572
+ ```typescript
1573
+ // Session cookie (expires when browser closes)
1574
+ await auth().login(user, { remember: false });
1575
+
1576
+ // Default (7 days)
1577
+ await auth().login(user);
1578
+
1579
+ // Persistent (30 days)
1580
+ await auth().login(user, { remember: true });
1581
+ ```
1582
+
1583
+ ---
1584
+
1585
+ ## Open Redirect Prevention
1586
+
1587
+ Always validate redirect URLs after login. Never redirect to user-supplied absolute URLs.
1588
+
1589
+ ```typescript
1590
+ // lib/safe-redirect.ts
1591
+ export function safeRedirect(url: string | null | undefined, fallback = '/'): string {
1592
+ if (
1593
+ !url ||
1594
+ !url.startsWith('/') ||
1595
+ url.startsWith('//') ||
1596
+ url.startsWith('/\\') ||
1597
+ url.includes('://')
1598
+ ) {
1599
+ return fallback;
1600
+ }
1601
+
1602
+ return url;
1603
+ }
1604
+ ```
1605
+
1606
+ Use it in login actions:
1607
+
1608
+ ```typescript
1609
+ import { safeRedirect } from '@/lib/safe-redirect';
1610
+
1611
+ // After successful login
1612
+ redirect(safeRedirect(redirectTo, '/dashboard'));
1613
+ ```
1614
+
1615
+ ---
1616
+
1617
+ ## Security Rules
1618
+
1619
+ ### Always enforced by ideal-auth:
1620
+ - `httpOnly: true` on session cookies — forced at runtime, cannot be overridden
1621
+ - `secure: true` when `NODE_ENV === 'production'`
1622
+ - `sameSite: 'lax'` by default
1623
+ - `path: '/'` by default
1624
+ - Secret must be 32+ characters — throws at startup if shorter
1625
+ - SHA-256 prehash for passwords > 72 bytes (prevents silent bcrypt truncation)
1626
+ - Timing-safe comparison for all secret/signature/TOTP operations
1627
+
1628
+ ### Your responsibility:
1629
+ - CSRF protection (framework-dependent — see each framework section)
1630
+ - Open redirect prevention (use `safeRedirect`)
1631
+ - Password minimum length enforcement (NIST recommends 8+)
1632
+ - Rate limiting on login, registration, and password reset endpoints
1633
+ - Using a persistent rate limit store in production (not in-memory)
1634
+ - Encrypting TOTP secrets at rest
1635
+ - Checking `iatMs` on tokens to prevent reuse after password change
1636
+ - Using different token secrets per use case
1637
+ - Setting `NODE_ENV=production` in production
1638
+ - Never logging passwords
1639
+ - Using parameterized queries/ORM (SQL injection prevention)
1640
+ - Content sanitization (XSS prevention)
1641
+
1642
+ ### CSRF by framework:
1643
+ | Framework | CSRF Protection |
1644
+ |-----------|----------------|
1645
+ | Next.js Server Actions | Built-in (automatic Origin validation) |
1646
+ | Next.js API Routes | Manual Origin validation needed |
1647
+ | SvelteKit form actions | Built-in (automatic Origin validation) |
1648
+ | Hono | Built-in `csrf()` middleware |
1649
+ | Express | Manual — implement Origin validation middleware |
1650
+ | Nuxt | Manual — implement Origin validation or use `nuxt-security` |
1651
+ | TanStack Start | Manual — validate Origin in server functions or middleware |
1652
+ | Elysia | Manual — validate Origin in `onBeforeHandle` |
1653
+
1654
+ ### Configuration Defaults
1655
+
1656
+ | Setting | Default | Notes |
1657
+ |---------|---------|-------|
1658
+ | Session secret | 32+ chars | Validated at startup |
1659
+ | Cookie name | `ideal_session` | Customizable |
1660
+ | Session maxAge | 604,800s (7 days) | Standard session |
1661
+ | Remember maxAge | 2,592,000s (30 days) | Remember me |
1662
+ | Cookie secure | `NODE_ENV === 'production'` | Auto |
1663
+ | Cookie sameSite | `lax` | CSRF protection |
1664
+ | Cookie path | `/` | Full domain |
1665
+ | Cookie httpOnly | `true` | Forced, not configurable |
1666
+ | Bcrypt rounds | 12 | ~250ms per hash |
1667
+ | TOTP digits | 6 | Standard |
1668
+ | TOTP period | 30s | RFC 6238 |
1669
+ | TOTP window | 1 | ±1 step (~90s acceptance) |
1670
+ | Token expiry | 3,600,000ms (1h) | Configurable |
1671
+ | Rate limit store | MemoryRateLimitStore | Use Redis/DB in prod |
1672
+
1673
+ ---
1674
+
1675
+ ## Troubleshooting
1676
+
1677
+ ### Session not persisting
1678
+ - **Next.js 15+:** Ensure `cookies()` is `await`ed in the cookie bridge
1679
+ - **Cookie bridge:** Ensure `set()` passes all three args (name, value, options) to the framework
1680
+ - **Cookie path:** Default is `'/'` — if overridden, ensure it covers all routes
1681
+
1682
+ ### Login works in dev but not production
1683
+ - `IDEAL_AUTH_SECRET` must be set in production env vars
1684
+ - Secret must be identical across all server instances/deploys
1685
+ - `NODE_ENV=production` must be set (controls `secure` cookie flag)
1686
+ - HTTPS must be enabled (secure cookies only sent over HTTPS)
1687
+
1688
+ ### `attempt()` always returns false
1689
+ - Check `resolveUserByCredentials` returns the user object (not null)
1690
+ - Ensure user has a `password` field with a bcrypt hash (starts with `$2a$` or `$2b$`)
1691
+ - If your password column is named differently, set `passwordField`
1692
+ - If your credential key is not `password`, set `credentialKey`
1693
+
1694
+ ### Cookie not set on localhost
1695
+ - Don't set `secure: true` explicitly in dev (default is `false` when not production)
1696
+ - `sameSite: 'none'` requires `secure: true`
1697
+ - Frontend and API on different ports? Browser may block as third-party cookie
1698
+
1699
+ ### TypeScript errors with user type
1700
+ - Pass your user type as generic: `createAuth<User>({ ... })`
1701
+ - User type must have `id: string | number`
1702
+
1703
+ ### TOTP codes not verifying
1704
+ - Server clock must be synced (use NTP)
1705
+ - Don't set `window: 0` — too strict for real-world use
1706
+ - Verify the TOTP secret round-trips correctly through storage
1707
+
1708
+ ### Rate limiter not working in production
1709
+ - `MemoryRateLimitStore` resets on process restart and is per-process
1710
+ - Use Redis-backed store for serverless/multi-instance deployments
1711
+
1712
+ ### Token verifier returns null
1713
+ - Same secret must be used for creation and verification
1714
+ - Check if token has expired (default: 1 hour)
1715
+ - Secret rotation invalidates all outstanding tokens
1716
+
1717
+ ---
1718
+
1719
+ ## Token Refresh (OAuth Access Tokens)
1720
+
1721
+ When using `sessionFields` to store OAuth access tokens in the session cookie, refresh them proactively before they expire. Do NOT wait for a 401 — by then the user's request already failed.
1722
+
1723
+ ### Setup
1724
+
1725
+ ```ts
1726
+ sessionFields: ['email', 'name', 'accessToken', 'refreshToken', 'expiresAt'],
1727
+ ```
1728
+
1729
+ Store `expiresAt` (Unix seconds) at login time: `Math.floor(Date.now() / 1000) + tokens.expires_in`.
1730
+
1731
+ ### Proactive refresh pattern
1732
+
1733
+ ```ts
1734
+ const REFRESH_BUFFER_SECONDS = 60;
1735
+
1736
+ async function ensureFreshToken() {
1737
+ const session = auth();
1738
+ const user = await session.user();
1739
+ if (!user) return null;
1740
+
1741
+ const now = Math.floor(Date.now() / 1000);
1742
+ if (user.expiresAt > now + REFRESH_BUFFER_SECONDS) return user; // still fresh
1743
+
1744
+ // Refresh the token
1745
+ const tokens = await refreshAccessToken(user.refreshToken);
1746
+ if (!tokens) { await session.logout(); return null; } // refresh failed
1747
+
1748
+ // Update session cookie with new tokens
1749
+ const updated = { ...user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: now + tokens.expiresIn };
1750
+ await session.login(updated);
1751
+ return updated;
1752
+ }
1753
+ ```
1754
+
1755
+ Key point: `auth().login()` replaces the session cookie — calling it with updated tokens IS the refresh mechanism.
1756
+
1757
+ ### Rules
1758
+ - Check `expiresAt` **before** making API calls, not after a 401
1759
+ - Use a 60-second buffer before expiry to prevent race conditions
1760
+ - When refresh fails, `logout()` and redirect to login
1761
+ - For refresh token rotation, prefer the new refresh token from the response
1762
+ - In high-concurrency environments, use a lock/mutex to prevent multiple simultaneous refreshes
1763
+
1764
+ ---
1765
+
1766
+ ## Multi-Tenant Cross-Domain Authentication
1767
+
1768
+ When tenants run on different domains (e.g., `acme.app.com`, `widgets.app.com`), cookies set on the central login domain cannot be read by tenant domains. Use a short-lived, one-time-use database token to transfer authentication across domains.
1769
+
1770
+ ### When to use this pattern
1771
+
1772
+ - Tenants are on **separate domains** (not subdomains of a shared parent)
1773
+ - Authentication happens on a **central login page** (identity domain)
1774
+ - OAuth providers redirect to the central app, not individual tenants
1775
+ - Cookies **cannot be shared** across tenant domains
1776
+
1777
+ If all tenants share a parent domain (e.g., `*.app.com`), set `domain: '.app.com'` on the session cookie instead — no cross-domain flow needed.
1778
+
1779
+ ### Database schema
1780
+
1781
+ ```sql
1782
+ CREATE TABLE login_sessions (
1783
+ id TEXT PRIMARY KEY, -- random lookup key
1784
+ token_hash TEXT NOT NULL, -- HMAC-SHA256 of the validation token
1785
+ user_id TEXT NOT NULL, -- user who authenticated
1786
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
1787
+ );
1788
+ ```
1789
+
1790
+ Two-part design: `id` for fast DB lookup, `token_hash` for cryptographic verification. Plaintext token is never stored — DB breach does not expose usable tokens. `userId` is stored plaintext (not a secret — no encryption overhead needed).
1791
+
1792
+ ### Transfer token module
1793
+
1794
+ ```ts
1795
+ import { generateToken, signData, timingSafeEqual } from 'ideal-auth';
1796
+
1797
+ const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
1798
+ const secret = process.env.TRANSFER_TOKEN_SECRET!; // separate from IDEAL_AUTH_SECRET
1799
+
1800
+ // After successful authentication on central app
1801
+ async function createTransferToken(userId: string) {
1802
+ const id = generateToken(20); // 40 hex chars — lookup key
1803
+ const token = generateToken(32); // 64 hex chars — validation secret
1804
+ const tokenHash = signData(token, secret);
1805
+ await db.insert(loginSessions).values({ id, tokenHash, userId });
1806
+ return { id, token }; // both included in redirect URL
1807
+ }
1808
+
1809
+ // On tenant callback endpoint
1810
+ async function validateTransferToken(id: string, token: string) {
1811
+ const deleted = await db.delete(loginSessions).where(eq(loginSessions.id, id))
1812
+ .returning({ tokenHash: loginSessions.tokenHash, userId: loginSessions.userId, createdAt: loginSessions.createdAt });
1813
+ if (deleted.length === 0) return null;
1814
+ const row = deleted[0];
1815
+ if (row.createdAt.getTime() < Date.now() - TOKEN_EXPIRY_MS) return null; // expired
1816
+ const candidateHash = signData(token, secret);
1817
+ if (!timingSafeEqual(candidateHash, row.tokenHash)) return null; // invalid token
1818
+ return { userId: row.userId };
1819
+ }
1820
+ ```
1821
+
1822
+ ### Tenant domain allowlist (central app)
1823
+
1824
+ ```ts
1825
+ // Central app MUST validate callbackUrl before redirecting
1826
+ const ALLOWED_TENANT_DOMAINS = process.env.ALLOWED_TENANT_DOMAINS!.split(',');
1827
+
1828
+ function validateTenantCallbackUrl(url: string | null): string | null {
1829
+ if (!url) return null;
1830
+ try {
1831
+ const parsed = new URL(url);
1832
+ if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') return null;
1833
+ if (!ALLOWED_TENANT_DOMAINS.includes(parsed.host)) return null;
1834
+ return url;
1835
+ } catch { return null; }
1836
+ }
1837
+ ```
1838
+
1839
+ ### Tenant auth setup with `attemptUser`
1840
+
1841
+ **With database (resolveUser):**
1842
+ ```ts
1843
+ const auth = createAuth<User>({
1844
+ secret: process.env.IDEAL_AUTH_SECRET!,
1845
+ cookie: createCookieBridge(),
1846
+ resolveUser: async (id) => db.user.findUnique({ where: { id } }),
1847
+ attemptUser: async (credentials) => {
1848
+ const data = await validateTransferToken(credentials.id, credentials.token);
1849
+ if (!data) return null;
1850
+ let user = await db.user.findUnique({ where: { id: data.userId } });
1851
+ if (!user) user = await db.user.create({ data: { id: data.userId } });
1852
+ return user;
1853
+ },
1854
+ });
1855
+ ```
1856
+
1857
+ **Without database (sessionFields) — user info stored in cookie:**
1858
+ ```ts
1859
+ const auth = createAuth<User>({
1860
+ secret: process.env.IDEAL_AUTH_SECRET!,
1861
+ cookie: createCookieBridge(),
1862
+ sessionFields: ['email', 'name', 'accessToken'],
1863
+ attemptUser: async (credentials) => {
1864
+ const data = await validateTransferToken(credentials.id, credentials.token);
1865
+ if (!data) return null;
1866
+ // Fetch profile from identity provider using the access token
1867
+ const res = await fetch('https://identity.example.com/api/userinfo', {
1868
+ headers: { Authorization: `Bearer ${data.accessToken}` },
1869
+ });
1870
+ if (!res.ok) return null;
1871
+ const profile = await res.json();
1872
+ return { id: profile.sub, email: profile.email, name: profile.name, accessToken: data.accessToken };
1873
+ },
1874
+ });
1875
+ // user() returns { id, email, name, accessToken } from cookie — zero API calls after login
1876
+ ```
1877
+
1878
+ ### Tenant callback endpoint
1879
+
1880
+ ```ts
1881
+ // GET /api/auth/callback?id=xxx&token=yyy&callbackUrl=/dashboard
1882
+ const id = request.searchParams.get('id');
1883
+ const token = request.searchParams.get('token');
1884
+ if (!id || !token) redirect('/login-failed');
1885
+ const session = auth();
1886
+ const success = await session.attempt({ id, token });
1887
+ if (success) redirect(safeRedirect(callbackUrl, '/dashboard'));
1888
+ ```
1889
+
1890
+ ### Central login redirect flow
1891
+
1892
+ ```
1893
+ Tenant (no session) → redirect to central login with callbackUrl
1894
+ Central login → validate callbackUrl against tenant allowlist → authenticate
1895
+ → createTransferToken(userId) → redirect to tenant callback with id + token
1896
+ Tenant callback → validateTransferToken(id, token) → attemptUser → session created
1897
+ ```
1898
+
1899
+ ### Security rules for multi-tenant auth
1900
+
1901
+ - **Validate callbackUrl first**: Central app must check against tenant domain allowlist before any redirect — this is the most critical check
1902
+ - **Two-part token**: Separate `id` (lookup) and `token` (validation) — neither alone is useful
1903
+ - **Timing-safe verification**: Use `timingSafeEqual` to compare HMAC hashes, preventing timing attacks
1904
+ - **Separate secret**: `TRANSFER_TOKEN_SECRET` must be different from `IDEAL_AUTH_SECRET`
1905
+ - **Same secret on all apps**: Central and all tenants share `TRANSFER_TOKEN_SECRET`
1906
+ - **One-time use**: Delete token from DB immediately after validation
1907
+ - **5-minute expiry**: Reject tokens older than 5 minutes
1908
+ - **HTTPS required**: Token travels in redirect URL — must be encrypted in transit
1909
+ - **Rate limit callback**: 10 attempts/minute per IP on the callback endpoint
1910
+ - **Clean up expired tokens**: Run a cron job hourly to delete old rows
1911
+ - **No payload encryption needed**: `userId` is not a secret — the real protection is the HMAC'd token, one-time use, and short expiry
1912
+ - **API endpoint auth**: If tenants validate via API (not shared DB), protect with API key or mTLS
1913
+
1914
+ ---
1915
+
1916
+ ## Federated Logout
1917
+
1918
+ When authenticating through a central identity provider (OAuth, OIDC, or custom login service), logging out requires a redirect chain that clears sessions on every domain.
1919
+
1920
+ ### Flow
1921
+
1922
+ ```
1923
+ Tenant: auth().logout() → clear local cookie → redirect to central /logout
1924
+ Central: auth().logout() → clear central cookie → redirect to OIDC provider logout (if applicable)
1925
+ Provider: clear provider session → redirect to post_logout_redirect_uri
1926
+ Final: redirect back to tenant origin
1927
+ ```
1928
+
1929
+ ### Tenant logout action
1930
+
1931
+ ```ts
1932
+ export async function logoutAction() {
1933
+ const session = auth();
1934
+ const user = await session.user();
1935
+ const idToken = user?.idToken; // needed for OIDC provider logout
1936
+
1937
+ await session.logout();
1938
+
1939
+ const logoutUrl = new URL(`${process.env.CENTRAL_LOGIN_URL}/logout`);
1940
+ logoutUrl.searchParams.set('callbackUrl', process.env.NEXT_PUBLIC_APP_URL!);
1941
+ if (idToken) logoutUrl.searchParams.set('id_token_hint', idToken);
1942
+ redirect(logoutUrl.toString());
1943
+ }
1944
+ ```
1945
+
1946
+ ### Central logout endpoint
1947
+
1948
+ ```ts
1949
+ // Clear central session, then redirect to OIDC provider (or back to tenant if no provider)
1950
+ const session = auth();
1951
+ await session.logout();
1952
+
1953
+ // With OIDC provider:
1954
+ const params = new URLSearchParams({ post_logout_redirect_uri: `${AUTH_URL}/logout/callback` });
1955
+ if (idTokenHint) params.set('id_token_hint', idTokenHint);
1956
+ redirect(`${OIDC_ISSUER_URL}/connect/logout?${params}`);
1957
+
1958
+ // Without OIDC provider:
1959
+ redirect(callbackUrl);
1960
+ ```
1961
+
1962
+ ### Key rules
1963
+ - `auth().logout()` only clears the **current domain's** cookie — cannot clear cookies on other domains
1964
+ - Each domain in the chain must be visited to clear its session
1965
+ - Validate `callbackUrl` on central logout endpoint (same allowlist as login)
1966
+ - Use `id_token_hint` for OIDC provider logout — without it, some providers show a confirmation page
1967
+ - For token revocation, call the provider's revocation endpoint before `logout()`
1968
+ - If you need instant revocation across all domains, add a server-side `sessions` table
1969
+ - Store `idToken` in `sessionFields` if needed for OIDC logout
1970
+
1971
+ ---
1972
+
1973
+ ## Production Checklist
1974
+
1975
+ Before deploying, verify:
1976
+
1977
+ - [ ] `IDEAL_AUTH_SECRET` is set, 32+ chars, not in version control
1978
+ - [ ] `NODE_ENV=production` is set
1979
+ - [ ] HTTPS is enabled
1980
+ - [ ] Session `maxAge` is appropriate for your use case
1981
+ - [ ] `httpOnly` is forced (default — do not strip in cookie bridge)
1982
+ - [ ] `secure` cookie flag is `true` in production
1983
+ - [ ] `sameSite` is `lax` (default) — only change if you understand the implications
1984
+ - [ ] Bcrypt rounds are 12+ (default)
1985
+ - [ ] Password minimum length is enforced (8+ characters)
1986
+ - [ ] Passwords are never logged
1987
+ - [ ] Login endpoint is rate limited
1988
+ - [ ] Registration endpoint is rate limited
1989
+ - [ ] Password reset endpoint is rate limited
1990
+ - [ ] Using persistent rate limit store in production (not in-memory)
1991
+ - [ ] CSRF protection is enabled for your framework
1992
+ - [ ] Token secrets are 32+ chars, different per use case
1993
+ - [ ] Token expiry is appropriate (reset: 1h, email: 24h, magic link: 15m)
1994
+ - [ ] Tokens are in URL paths, not query strings
1995
+ - [ ] Token `iatMs` is checked against relevant timestamps
1996
+ - [ ] TOTP secret is stored encrypted in database
1997
+ - [ ] Recovery codes are shown once, stored hashed
1998
+ - [ ] Post-login redirects are validated with `safeRedirect`
1999
+ - [ ] Error messages don't leak user existence ("Invalid email or password")
2000
+ - [ ] Secret rotation plan exists for emergency session invalidation