From ideal-auth
Auth primitives for the JS ecosystem. Handles login, registration, sessions, password hashing, TOTP 2FA, CSRF, rate limiting, password reset, email verification, token management, and cookie-backed sessions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ideal-auth:ideal-authThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
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:
questions:
- question: "Which framework are you using?"
header: "Framework"
options:
- label: "Next.js (App Router)"
description: "React framework with Server Actions and middleware"
- label: "SvelteKit"
description: "Svelte framework with form actions and hooks"
- label: "Express"
description: "Node.js HTTP framework"
- label: "Hono"
description: "Lightweight framework for Node, Bun, Deno, Workers"
multiSelect: false
- question: "Which auth features do you need?"
header: "Features"
options:
- label: "Login + Registration"
description: "Email/password auth with session management"
- label: "Password Reset"
description: "Forgot password flow with email tokens"
- label: "Two-Factor Auth (TOTP)"
description: "Authenticator app + recovery codes"
- label: "Rate Limiting"
description: "Brute-force protection on login"
multiSelect: true
Additional framework options to offer if the user picks "Other": Nuxt, TanStack Start, Elysia.
Auth primitives for the JS ecosystem. Zero framework dependencies. Inspired by Laravel's Auth and Hash facades.
Install: bun add ideal-auth (or npm install ideal-auth)
Generate secret: bunx ideal-auth secret (outputs IDEAL_AUTH_SECRET=... for .env)
Generate encryption key: bunx ideal-auth encryption-key (for encrypting TOTP secrets at rest)
createAuth(config): (options?) => AuthInstanceReturns a factory function. Call auth() per request to get an AuthInstance scoped to that request's cookies. Pass { autoTouch: true } to enable automatic session extension for that request. The instance caches the session payload and user — call it once per request and reuse.
const session = auth(); // read-only check/user/id
const session = auth({ autoTouch: true }); // auto-extends past halfway on check/user/id
// TUser = session user type — what user() returns. Do not include password.
// Provide exactly ONE of resolveUser or sessionFields.
// TypeScript will error if you provide both or neither.
type AuthConfig<TUser> =
| {
resolveUser: (id: string) => Promise<TUser | null | undefined>;
sessionFields?: never;
// resolveUserByCredentials can return any shape — only needs id + passwordField
resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<AnyUser | null | undefined>;
hash?: HashInstance;
attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
// ...session, secret, cookie
}
| {
resolveUser?: never;
sessionFields: (keyof TUser & string)[];
resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<AnyUser | null | undefined>;
hash?: HashInstance;
attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
// ...session, secret, cookie
};
Database-backed (resolveUser): Cookie stores only user ID. user() calls resolveUser(id) every request. Type TUser as the safe type — only select non-sensitive columns.
type SafeUser = { id: string; email: string; name: string; role: string };
const auth = createAuth<SafeUser>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
// Returns SafeUser — no password
resolveUser: async (id) => db.user.findFirst({
where: { id },
columns: { id: true, email: true, name: true, role: true },
}),
// Can return full DB row — only used internally for hash verification
resolveUserByCredentials: async (creds) => db.user.findFirst({
where: { email: creds.email },
}),
hash,
});
const user = await auth().user(); // Type: SafeUser | null
Cookie-backed (sessionFields): Cookie stores user ID + declared fields. user() returns TUser | null. Since TUser should not include password, the type is already safe.
type SessionUser = { id: string; email: string; name: string; role: string }; // no password
const auth = createAuth<SessionUser>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
sessionFields: ['email', 'name', 'role'],
resolveUserByCredentials: async (creds) => db.user.findFirst({ where: { email: creds.email } }),
hash,
});
const user = await auth().user(); // Type: SessionUser | null
Key rules:
TUser is the session user type — what user() returns. Do not include password.resolveUser or sessionFields — TypeScript errors if both or neitherresolveUserByCredentials returns AnyUser — doesn't need to match TUser, only needs id + password fieldattempt() — even same-request user() won't expose itloginById() requires resolveUser — throws with sessionFieldsuser() directly to the client — always pick the specific fields you need| Method | Returns | Description |
|---|---|---|
login(user, options?) | Promise<void> | Set session cookie for the given user |
loginById(id, options?) | Promise<void> | Resolve user by ID, then set session cookie (requires resolveUser) |
attempt(credentials, options?) | Promise<boolean> | Find user, verify password, login if valid |
logout() | Promise<void> | Delete session cookie |
check() | Promise<boolean> | Is the session valid? (fast, cached) |
user() | Promise<TUser | null> | Get the authenticated user (from DB with resolveUser, or from cookie with sessionFields) |
id() | Promise<string | null> | Get the authenticated user's ID |
touch() | Promise<void> | Re-seal the session cookie with a fresh expiry. No database call needed. |
type LoginOptions = {
remember?: boolean;
// true: use rememberMaxAge (30 days)
// false: session cookie (expires when browser closes)
// undefined: use default maxAge (7 days)
};
Three ways to extend sessions for active users:
Global autoTouch — config level. For Express, Hono, Elysia, SvelteKit where every route can write cookies:
const auth = createAuth<User>({
session: { autoTouch: true },
...
});
Per-request autoTouch — pass when calling auth(). Ideal for Next.js where middleware can write cookies but Server Components can't:
// Next.js middleware — autoTouch for this request only
const session = auth({ autoTouch: true });
await session.check(); // auto-extends past halfway
// Next.js Server Component — default, read-only
const session = auth();
await session.check(); // no cookie writes
Manual touch() — explicit call in middleware. When autoTouch is false (default), only reseals past halfway. When autoTouch is true, reseals immediately:
const session = auth();
if (await session.check()) {
await session.touch();
}
autoTouch: false (default) | autoTouch: true | |
|---|---|---|
check()/user()/id() | Read-only | Auto-reseals past halfway |
touch() | Reseals past halfway | Reseals immediately |
touch() preserves original iat — passwordChangedAt invalidation still works. Does not update user data — use auth().login(updatedUser) for that.
attempt() — Two ModesLaravel-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.
const auth = createAuth({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
hash,
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
resolveUserByCredentials: async (creds) => {
// creds = { email: '...' } — password already stripped
return db.user.findUnique({ where: { email: creds.email } });
},
});
Manual (escape hatch): Provide attemptUser for full control over lookup and verification. Takes precedence if both are provided.
const auth = createAuth({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
attemptUser: async (creds) => {
const user = await db.user.findUnique({ where: { email: creds.email } });
if (!user) return null;
if (!(await hash.verify(creds.password, user.password))) return null;
return user;
},
});
Password hashing is only needed for the Laravel-style attempt() flow (hash + resolveUserByCredentials). If you use attemptUser, login(user) directly, or sessionFields without credential verification, no HashInstance or bcryptjs is required.
ideal-auth accepts any HashInstance:
type HashInstance = {
make(password: string): Promise<string>;
verify(password: string, hash: string): Promise<boolean>;
};
createHash() (bcryptjs) — built-in convenience. Requires bcryptjs as an optional peer dependency (bun add bcryptjs). Throws a clear error if not installed.
import { createHash } from 'ideal-auth';
const hash = createHash({ rounds: 12 }); // default: 12
const hashed = await hash.make('password');
const valid = await hash.verify('password', hashed); // true
Custom hash (bring your own) — use your runtime's native hashing or a different algorithm. No bcryptjs needed.
import { prehash } from 'ideal-auth';
// Bun native bcrypt (faster than bcryptjs — use prehash for 72-byte limit)
const hash: HashInstance = {
make: (password) => Bun.password.hash(prehash(password), { algorithm: 'bcrypt', cost: 12 }),
verify: (password, hash) => Bun.password.verify(prehash(password), hash),
};
// Bun argon2id (OWASP recommended — no prehash needed, no input length limit)
const hash: HashInstance = {
make: (password) => Bun.password.hash(password, { algorithm: 'argon2id', memoryCost: 65536, timeCost: 2 }),
verify: (password, hash) => Bun.password.verify(password, hash),
};
// Node argon2 (requires: bun add argon2 — no prehash needed)
import argon2 from 'argon2';
const hash: HashInstance = {
make: (password) => argon2.hash(password),
verify: (password, hash) => argon2.verify(hash, password),
};
Pass either to createAuth:
const auth = createAuth({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
hash, // createHash() or your custom HashInstance
resolveUserByCredentials: async (creds) =>
db.user.findUnique({ where: { email: creds.email } }),
});
createTokenVerifier(config): TokenVerifierInstanceSigned, expiring tokens for password resets, email verification, magic links, invites. Create one instance per use case with its own secret/expiry.
type TokenVerifierConfig = {
secret: string; // 32+ chars, required
expiryMs?: number; // default: 3600000 (1 hour)
};
type TokenVerifierInstance = {
createToken(userId: string): string;
verifyToken(token: string): { userId: string; iatMs: number } | null;
};
Token format: encodedUserId.randomId.issuedAtMs.expiryMs.signature (HMAC-SHA256 signed).
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.
createTOTP(config?): TOTPInstanceRFC 6238 TOTP generation and verification.
type TOTPConfig = {
digits?: number; // default: 6
period?: number; // default: 30 (seconds)
window?: number; // default: 1 (±1 time step, ~90 second acceptance window)
};
type TOTPInstance = {
generateSecret(): string; // 32-char base32 string
generateQrUri(opts: { secret: string; issuer: string; account: string }): string;
verify(token: string, secret: string): boolean;
};
generateRecoveryCodes(hash, count?): Promise<{ codes, hashed }>Generate backup codes for 2FA recovery. Returns plaintext codes (show once to user) and bcrypt-hashed codes (store in DB).
import { generateRecoveryCodes, verifyRecoveryCode, createHash } from 'ideal-auth';
const hash = createHash();
const { codes, hashed } = await generateRecoveryCodes(hash, 8);
// codes: string[] — show to user once (format: XXXXXXXX-XXXXXXXX)
// hashed: string[] — store in database
const { valid, remaining } = await verifyRecoveryCode(code, storedHashes, hash);
// valid: boolean
// remaining: string[] — update DB with this (removes used code)
createRateLimiter(config): RateLimiterInstancetype RateLimiterConfig = {
maxAttempts: number; // required
windowMs: number; // required
store?: RateLimitStore; // default: MemoryRateLimitStore
};
type RateLimitResult = {
allowed: boolean;
remaining: number;
resetAt: Date;
};
// Methods: attempt(key): Promise<RateLimitResult>, reset(key): Promise<void>
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.
Custom store interface:
interface RateLimitStore {
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
reset(key: string): Promise<void>;
}
All use node:crypto — no third-party dependencies.
import {
generateToken,
signData,
verifySignature,
encrypt,
decrypt,
timingSafeEqual,
} from 'ideal-auth';
// Random hex token (default 32 bytes = 64 hex chars)
const token = generateToken();
const short = generateToken(16); // 32 hex chars
// HMAC-SHA256 signing
const sig = signData('user:123:reset', secret);
const valid = verifySignature('user:123:reset', sig, secret);
// AES-256-GCM encryption (scrypt key derivation, base64url output)
const encrypted = await encrypt('sensitive data', secret);
const decrypted = await decrypt(encrypted, secret);
// Constant-time string comparison
timingSafeEqual('abc', 'abc'); // true
type AnyUser = { id: string | number; [key: string]: any };
type CookieBridge = {
get(name: string): Promise<string | undefined> | string | undefined;
set(name: string, value: string, options: CookieOptions): Promise<void> | void;
delete(name: string): Promise<void> | void;
};
type CookieOptions = {
httpOnly?: boolean;
secure?: boolean;
sameSite?: 'lax' | 'strict' | 'none';
path?: string;
maxAge?: number;
expires?: Date;
domain?: string;
};
// httpOnly is always forced to true — not configurable
type ConfigurableCookieOptions = Omit<CookieOptions, 'httpOnly'>;
type SessionPayload = {
uid: string; // user ID (always string)
iat: number; // issued-at (Unix seconds)
exp: number; // expiration (Unix seconds)
};
// lib/cookies.ts
import { cookies } from 'next/headers';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(): CookieBridge {
return {
async get(name: string) {
const cookieStore = await cookies();
return cookieStore.get(name)?.value;
},
async set(name, value, options) {
const cookieStore = await cookies();
cookieStore.set(name, value, options);
},
async delete(name) {
const cookieStore = await cookies();
cookieStore.delete(name);
},
};
}
IMPORTANT: cookies() is async in Next.js 15+. If on Next.js 14, remove the await.
Auth setup:
// lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from './db';
type User = {
id: string;
email: string;
name: string;
password: string;
};
const hash = createHash({ rounds: 12 });
const auth = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({
where: { email: credentials.email },
});
},
});
export { auth, hash };
createAuth returns a factory function. Call auth() inside each Server Action. Do not call at module level.
Login action:
// app/actions/login.ts
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export async function loginAction(_prev: unknown, formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const remember = formData.get('remember') === 'on';
if (!email || !password) {
return { error: 'Email and password are required.' };
}
const session = auth();
const success = await session.attempt(
{ email, password },
{ remember },
);
if (!success) {
return { error: 'Invalid email or password.' };
}
redirect('/dashboard');
}
Login form:
// app/login/page.tsx
'use client';
import { useActionState } from 'react';
import { loginAction } from '@/app/actions/login';
export default function LoginPage() {
const [state, formAction, pending] = useActionState(loginAction, null);
return (
<form action={formAction}>
{state?.error && <p className="text-red-500">{state.error}</p>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<label>
<input name="remember" type="checkbox" /> Remember me
</label>
<button type="submit" disabled={pending}>
{pending ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
Registration action:
// app/actions/register.ts
'use server';
import { redirect } from 'next/navigation';
import { auth, hash } from '@/lib/auth';
import { db } from '@/lib/db';
export async function registerAction(_prev: unknown, formData: FormData) {
const email = formData.get('email') as string;
const name = formData.get('name') as string;
const password = formData.get('password') as string;
const passwordConfirmation = formData.get('password_confirmation') as string;
if (!email || !name || !password) {
return { error: 'All fields are required.' };
}
if (password.length < 8) {
return { error: 'Password must be at least 8 characters.' };
}
if (password !== passwordConfirmation) {
return { error: 'Passwords do not match.' };
}
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return { error: 'An account with this email already exists.' };
}
const user = await db.user.create({
data: {
email,
name,
password: await hash.make(password),
},
});
const session = auth();
await session.login(user);
redirect('/dashboard');
}
Logout action:
// app/actions/logout.ts
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export async function logoutAction() {
const session = auth();
await session.logout();
redirect('/login');
}
Middleware (route protection):
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
const authRoutes = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasSession = request.cookies.has('ideal_session');
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
if (!hasSession) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
}
if (authRoutes.some((route) => pathname.startsWith(route))) {
if (hasSession) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*', '/login', '/register'],
};
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().
Server-side auth guard helper:
// lib/auth-guard.ts
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export async function requireAuth() {
const session = auth();
const user = await session.user();
if (!user) {
redirect('/login');
}
return user;
}
Getting the current user in a Server Component:
// app/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = auth();
const user = await session.user();
if (!user) redirect('/login');
return <h1>Welcome, {user.name}</h1>;
}
Pass user data to Client Components as props — only pass serializable, non-sensitive fields.
CSRF: Next.js Server Actions have built-in CSRF protection (Origin header validation). For API Route Handlers, validate the Origin header manually:
// app/api/example/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const headerStore = await headers();
const origin = headerStore.get('origin');
const host = headerStore.get('host');
if (!origin || new URL(origin).host !== host) {
return NextResponse.json({ error: 'Invalid origin' }, { status: 403 });
}
// ... handle request
}
Edge Runtime: Use export const runtime = 'nodejs' in any Route Handler that calls auth().
// src/lib/server/cookies.ts
import type { Cookies } from '@sveltejs/kit';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(cookies: Cookies): CookieBridge {
return {
get(name: string) {
return cookies.get(name);
},
set(name, value, options) {
cookies.set(name, value, {
...options,
path: options.path ?? '/',
});
},
delete(name) {
cookies.delete(name, { path: '/' });
},
};
}
SvelteKit requires an explicit path on cookies.set().
Auth setup:
// src/lib/server/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from '$lib/server/db';
import { IDEAL_AUTH_SECRET } from '$env/static/private';
type User = { id: string; email: string; name: string; password: string };
export const hash = createHash({ rounds: 12 });
export function auth(cookies: import('@sveltejs/kit').Cookies) {
const authFactory = createAuth<User>({
secret: IDEAL_AUTH_SECRET,
cookie: createCookieBridge(cookies),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({ where: { email: credentials.email } });
},
});
return authFactory();
}
Pass cookies from the RequestEvent each time — keeps requests isolated.
Login (form action):
// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const session = auth(cookies);
if (await session.check()) redirect(303, '/dashboard');
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email') as string;
const password = data.get('password') as string;
const remember = data.get('remember') === 'on';
if (!email || !password) {
return fail(400, { error: 'Email and password are required.', email });
}
const session = auth(cookies);
const success = await session.attempt({ email, password }, { remember });
if (!success) {
return fail(400, { error: 'Invalid email or password.', email });
}
redirect(303, '/dashboard');
},
};
Registration (form action):
// src/routes/register/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { auth, hash } from '$lib/server/auth';
import { db } from '$lib/server/db';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email') as string;
const name = data.get('name') as string;
const password = data.get('password') as string;
const passwordConfirmation = data.get('password_confirmation') as string;
if (!email || !name || !password) {
return fail(400, { error: 'All fields are required.', email, name });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters.', email, name });
}
if (password !== passwordConfirmation) {
return fail(400, { error: 'Passwords do not match.', email, name });
}
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return fail(400, { error: 'An account with this email already exists.', email, name });
}
const user = await db.user.create({
data: { email, name, password: await hash.make(password) },
});
const session = auth(cookies);
await session.login(user);
redirect(303, '/dashboard');
},
};
Auth guard (handle hook):
// src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
const authRoutes = ['/login', '/register'];
export const handle: Handle = async ({ event, resolve }) => {
const session = auth(event.cookies);
const user = await session.user();
event.locals.user = user
? { id: user.id, email: user.email, name: user.name }
: null;
const { pathname } = event.url;
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
if (!user) redirect(303, `/login?callbackUrl=${encodeURIComponent(pathname)}`);
}
if (authRoutes.some((route) => pathname.startsWith(route))) {
if (user) redirect(303, '/dashboard');
}
return resolve(event);
};
Declare types in src/app.d.ts:
declare global {
namespace App {
interface Locals {
user: { id: string; email: string; name: string } | null;
}
}
}
export {};
CSRF: SvelteKit has built-in CSRF protection — auto-validates Origin header on all form submissions. Do not set checkOrigin: false in production.
Requires cookie-parser: bun add cookie-parser + bun add -D @types/cookie-parser @types/express
// src/lib/cookies.ts
import type { Request, Response } from 'express';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(req: Request, res: Response): CookieBridge {
return {
get(name: string) {
return req.cookies[name];
},
set(name, value, options) {
res.cookie(name, value, {
httpOnly: options.httpOnly,
secure: options.secure,
sameSite: options.sameSite,
path: options.path ?? '/',
...(options.maxAge !== undefined && { maxAge: options.maxAge * 1000 }),
});
},
delete(name) {
res.clearCookie(name, { path: '/' });
},
};
}
IMPORTANT: Express res.cookie() uses milliseconds for maxAge, but ideal-auth provides seconds. The bridge multiplies by 1000.
Auth setup:
// src/lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from './db';
type User = { id: string; email: string; name: string; password: string };
export const hash = createHash({ rounds: 12 });
export function auth(req: import('express').Request, res: import('express').Response) {
const authFactory = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(req, res),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({ where: { email: credentials.email } });
},
});
return authFactory();
}
App setup:
// src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(csrfProtection); // see CSRF section below
Auth middleware:
// src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';
import { auth } from '../lib/auth';
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const session = auth(req, res);
const user = await session.user();
if (!user) {
return res.status(401).json({ error: 'Authentication required.' });
}
req.user = user;
next();
}
Extend Express Request type:
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: { id: string; email: string; name: string };
}
}
}
export {};
CSRF: Express has NO built-in CSRF protection. Implement Origin header validation:
// src/middleware/csrf.ts
import type { Request, Response, NextFunction } from 'express';
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
export function csrfProtection(req: Request, res: Response, next: NextFunction) {
if (SAFE_METHODS.includes(req.method)) return next();
const origin = req.get('origin');
const host = req.get('host');
if (!origin || !host) {
return res.status(403).json({ error: 'Forbidden: missing origin header.' });
}
try {
if (new URL(origin).host !== host) {
return res.status(403).json({ error: 'Forbidden: origin mismatch.' });
}
} catch {
return res.status(403).json({ error: 'Forbidden: invalid origin.' });
}
next();
}
For HTML forms, use a CSRF token approach with generateToken and timingSafeEqual from ideal-auth.
// src/lib/cookies.ts
import type { Context } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(c: Context): CookieBridge {
return {
get(name: string) {
return getCookie(c, name);
},
set(name, value, options) {
setCookie(c, name, value, {
httpOnly: options.httpOnly,
secure: options.secure,
sameSite: options.sameSite === 'lax' ? 'Lax' : options.sameSite === 'strict' ? 'Strict' : 'None',
path: options.path ?? '/',
...(options.maxAge !== undefined && { maxAge: options.maxAge }),
});
},
delete(name) {
deleteCookie(c, name, { path: '/' });
},
};
}
Note: Hono expects capitalized sameSite values ('Lax', 'Strict', 'None'). The bridge converts.
Auth setup:
// src/lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from './db';
type User = { id: string; email: string; name: string; password: string };
export const hash = createHash({ rounds: 12 });
export function auth(c: import('hono').Context) {
const authFactory = createAuth<User>({
secret: c.env?.IDEAL_AUTH_SECRET ?? process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(c),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({ where: { email: credentials.email } });
},
});
return authFactory();
}
Auth middleware:
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory';
import { auth } from '../lib/auth';
type Env = {
Variables: { user: { id: string; email: string; name: string } };
};
export const requireAuth = createMiddleware<Env>(async (c, next) => {
const session = auth(c);
const user = await session.user();
if (!user) return c.json({ error: 'Authentication required.' }, 401);
c.set('user', { id: user.id, email: user.email, name: user.name });
await next();
});
CSRF: Hono has built-in csrf() middleware:
import { csrf } from 'hono/csrf';
app.use(csrf());
Cloudflare Workers: Add nodejs_compat compatibility flag to wrangler.toml. Access env via c.env.IDEAL_AUTH_SECRET.
// server/utils/cookies.ts
import type { H3Event } from 'h3';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(event: H3Event): CookieBridge {
return {
get(name: string) {
return getCookie(event, name);
},
set(name, value, options) {
setCookie(event, name, value, options);
},
delete(name) {
deleteCookie(event, name, { path: '/' });
},
};
}
getCookie, setCookie, deleteCookie are auto-imported from h3 in Nuxt server routes.
Auth setup:
// server/utils/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
type User = { id: string; email: string; name: string; password: string };
export const hash = createHash({ rounds: 12 });
export function auth(event: H3Event) {
const config = useRuntimeConfig();
const authFactory = createAuth<User>({
secret: config.idealAuthSecret,
cookie: createCookieBridge(event),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({ where: { email: credentials.email } });
},
});
return authFactory();
}
Register the secret in nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
idealAuthSecret: process.env.IDEAL_AUTH_SECRET,
},
});
Files in server/utils/ are auto-imported. Call auth(event) directly.
Login API route:
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!body.email || !body.password) {
throw createError({ statusCode: 400, statusMessage: 'Email and password are required.' });
}
const session = auth(event);
const success = await session.attempt(
{ email: body.email, password: body.password },
{ remember: body.remember ?? false },
);
if (!success) {
throw createError({ statusCode: 401, statusMessage: 'Invalid email or password.' });
}
return { success: true };
});
CSRF: Nuxt has NO built-in CSRF protection. Implement Origin header validation as server middleware, or use the nuxt-security module.
// app/lib/cookies.ts
import { getCookie, setCookie, deleteCookie } from 'vinxi/http';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(): CookieBridge {
return {
get(name: string) {
return getCookie(name);
},
set(name, value, options) {
setCookie(name, value, options);
},
delete(name) {
deleteCookie(name, { path: '/' });
},
};
}
IMPORTANT: vinxi/http cookie functions use async local storage — must be called within a createServerFn handler or server middleware.
Auth setup:
// app/lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from './db';
type User = { id: string; email: string; name: string; password: string };
export const hash = createHash({ rounds: 12 });
const authFactory = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({ where: { email: credentials.email } });
},
});
export function auth() {
return authFactory();
}
Server functions:
// app/lib/auth.actions.ts
import { createServerFn } from '@tanstack/start';
import { auth, hash } from './auth';
import { db } from './db';
export const loginFn = createServerFn({ method: 'POST' })
.validator((data: { email: string; password: string; remember?: boolean }) => data)
.handler(async ({ data }) => {
if (!data.email || !data.password) throw new Error('Email and password are required.');
const session = auth();
const success = await session.attempt(
{ email: data.email, password: data.password },
{ remember: data.remember ?? false },
);
if (!success) throw new Error('Invalid email or password.');
return { success: true };
});
export const getCurrentUserFn = createServerFn({ method: 'GET' })
.handler(async () => {
const session = auth();
const user = await session.user();
if (!user) return { user: null };
return { user: { id: user.id, email: user.email, name: user.name } };
});
Route protection with beforeLoad:
// app/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getCurrentUserFn } from '../lib/auth.actions';
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const { user } = await getCurrentUserFn();
if (!user) {
throw redirect({ to: '/login', search: { callbackUrl: '/dashboard' } });
}
return { user };
},
component: DashboardPage,
});
function DashboardPage() {
const { user } = Route.useRouteContext();
return <h1>Welcome, {user.name}</h1>;
}
CSRF: TanStack Start has NO built-in CSRF protection. Validate the Origin header manually.
// src/lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import type { Context } from 'elysia';
import { db } from './db';
export const hash = createHash({ rounds: 12 });
export function auth(ctx: Context) {
const { cookie } = ctx;
return createAuth({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: {
get: (name) => cookie[name]?.value,
set: (name, value, opts) => {
cookie[name].set({
value,
httpOnly: opts.httpOnly,
secure: opts.secure,
sameSite: opts.sameSite,
path: opts.path,
maxAge: opts.maxAge,
});
},
delete: (name) => cookie[name].remove(),
},
hash,
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
resolveUserByCredentials: async (creds) =>
db.user.findUnique({ where: { email: creds.email } }),
});
}
Note: auth(ctx) returns the factory. Call auth(ctx)() to get the instance.
Auth middleware with derive:
// src/middleware/auth.ts
import { Elysia } from 'elysia';
import { auth } from '../lib/auth';
export const requireAuth = new Elysia({ name: 'requireAuth' })
.derive(async (ctx) => {
const session = auth(ctx)();
const user = await session.user();
if (!user) {
ctx.set.status = 401;
throw new Error('Unauthorized');
}
return { user };
});
CSRF: Elysia has no built-in CSRF. Validate Origin header via onBeforeHandle.
import { createTokenVerifier, createHash } from 'ideal-auth';
const passwordReset = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET! + '-reset', // use a different secret per use case
expiryMs: 60 * 60 * 1000, // 1 hour
});
// Step 1: User requests reset
const token = passwordReset.createToken(user.id);
await sendEmail(user.email, `https://app.com/reset/${token}`);
// Put token in URL path, NOT query string (query strings are logged)
// Step 2: User clicks link
const result = passwordReset.verifyToken(token);
if (!result) throw new Error('Invalid or expired token');
// Step 3: Validate token hasn't been used (CRITICAL — tokens are stateless)
if (result.iatMs < user.passwordChangedAt) {
throw new Error('Token already used');
}
// Step 4: Update password
const hash = createHash();
await db.user.update({
where: { id: result.userId },
data: {
password: await hash.make(newPassword),
passwordChangedAt: Date.now(),
},
});
const emailVerification = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET! + '-email',
expiryMs: 24 * 60 * 60 * 1000, // 24 hours
});
// After registration
const token = emailVerification.createToken(user.id);
await sendEmail(user.email, `https://app.com/verify/${token}`);
// Verify
const result = emailVerification.verifyToken(token);
if (!result) throw new Error('Invalid or expired token');
await db.user.update({
where: { id: result.userId },
data: { emailVerifiedAt: new Date() },
});
Setup phase:
import { createTOTP, createHash, encrypt, generateRecoveryCodes } from 'ideal-auth';
const totp = createTOTP();
const hash = createHash();
// 1. Generate secret
const secret = totp.generateSecret();
// 2. Create QR code URI
const uri = totp.generateQrUri({
secret,
issuer: 'MyApp',
account: user.email,
});
// Render uri as QR code with any QR library
// 3. Verify user can produce a valid code
if (!totp.verify(codeFromAuthenticator, secret)) {
throw new Error('Invalid setup code');
}
// 4. Store secret encrypted
await db.user.update({
where: { id: user.id },
data: {
totpSecret: await encrypt(secret, process.env.ENCRYPTION_KEY!),
totpEnabled: true,
},
});
// 5. Generate recovery codes
const { codes, hashed } = await generateRecoveryCodes(hash, 8);
// Show codes to user ONCE, store hashed in DB
await db.user.update({
where: { id: user.id },
data: { recoveryCodes: hashed },
});
Login with 2FA:
import { decrypt } from 'ideal-auth';
// After password verification, check if 2FA is enabled
if (user.totpEnabled) {
const decryptedSecret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY!);
if (!totp.verify(codeFromUser, decryptedSecret)) {
throw new Error('Invalid 2FA code');
}
}
await auth().login(user);
Recovery code login:
import { verifyRecoveryCode } from 'ideal-auth';
const { valid, remaining } = await verifyRecoveryCode(code, user.recoveryCodes, hash);
if (valid) {
await db.user.update({
where: { id: user.id },
data: { recoveryCodes: remaining },
});
await auth().login(user);
}
'use server';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import { createRateLimiter } from 'ideal-auth';
const limiter = createRateLimiter({
maxAttempts: 5,
windowMs: 60_000, // 1 minute
});
export async function loginAction(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const headerStore = await headers();
const ip = headerStore.get('x-forwarded-for') ?? '127.0.0.1';
const key = `login:${ip}`;
const { allowed, remaining, resetAt } = await limiter.attempt(key);
if (!allowed) {
const seconds = Math.ceil((resetAt.getTime() - Date.now()) / 1000);
redirect(`/?error=rate_limit&retry=${seconds}`);
}
const session = auth();
const success = await session.attempt({ email, password });
if (!success) {
redirect(`/?error=invalid&remaining=${remaining}`);
}
await limiter.reset(key);
redirect('/');
}
// Session cookie (expires when browser closes)
await auth().login(user, { remember: false });
// Default (7 days)
await auth().login(user);
// Persistent (30 days)
await auth().login(user, { remember: true });
Passkeys use public-key cryptography for passwordless authentication. ideal-auth handles the session after verification — the WebAuthn protocol is handled by @simplewebauthn/server and @simplewebauthn/browser.
No hash or bcryptjs needed — there are no passwords.
Registration: browser creates key pair → store public key in DB
Authentication: server sends challenge → browser signs it → server verifies → auth().login(user)
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { auth } from './auth';
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter },
});
if (verification.verified) {
// Update signature counter (replay protection)
await db.passkey.update({ where: { id: passkey.id }, data: { counter: Number(verification.authenticationInfo.newCounter) } });
const user = await db.user.findUnique({ where: { id: passkey.userId } });
await auth().login(user); // session created — done
}
bun add @simplewebauthn/server @simplewebauthn/browserrpID to root domain (e.g., example.com) — works across subdomainsuserVerification: 'preferred' for most apps, 'required' for high-securityAlways validate redirect URLs after login. Never redirect to user-supplied absolute URLs.
// lib/safe-redirect.ts
export function safeRedirect(url: string | null | undefined, fallback = '/'): string {
if (
!url ||
!url.startsWith('/') ||
url.startsWith('//') ||
url.startsWith('/\\') ||
url.includes('://')
) {
return fallback;
}
return url;
}
Use it in login actions:
import { safeRedirect } from '@/lib/safe-redirect';
// After successful login
redirect(safeRedirect(redirectTo, '/dashboard'));
httpOnly: true on session cookies — forced at runtime, cannot be overriddensecure: true when NODE_ENV === 'production'sameSite: 'lax' by defaultpath: '/' by defaultsafeRedirect)iatMs on tokens to prevent reuse after password changeNODE_ENV=production in production| Framework | CSRF Protection |
|---|---|
| Next.js Server Actions | Built-in (automatic Origin validation) |
| Next.js API Routes | Manual Origin validation needed |
| SvelteKit form actions | Built-in (automatic Origin validation) |
| Hono | Built-in csrf() middleware |
| Express | Manual — implement Origin validation middleware |
| Nuxt | Manual — implement Origin validation or use nuxt-security |
| TanStack Start | Manual — validate Origin in server functions or middleware |
| Elysia | Manual — validate Origin in onBeforeHandle |
| Setting | Default | Notes |
|---|---|---|
IDEAL_AUTH_SECRET | 32+ chars | Validated at startup |
| Cookie name | ideal_session | Customizable |
| Session maxAge | 604,800s (7 days) | Standard session |
| Remember maxAge | 2,592,000s (30 days) | Remember me |
| Cookie secure | NODE_ENV === 'production' | Auto |
| Cookie sameSite | lax | CSRF protection |
| Cookie path | / | Full domain |
| Cookie httpOnly | true | Forced, not configurable |
| Bcrypt rounds | 12 | ~250ms per hash |
| TOTP digits | 6 | Standard |
| TOTP period | 30s | RFC 6238 |
| TOTP window | 1 | ±1 step (~90s acceptance) |
| Token expiry | 3,600,000ms (1h) | Configurable |
| Rate limit store | MemoryRateLimitStore | Use Redis/DB in prod |
cookies() is awaited in the cookie bridgeset() passes all three args (name, value, options) to the framework'/' — if overridden, ensure it covers all routesIDEAL_AUTH_SECRET must be set in production env varsNODE_ENV=production must be set (controls secure cookie flag)attempt() always returns falseresolveUserByCredentials returns the user object (not null)password field with a bcrypt hash (starts with $2a$ or $2b$)passwordFieldpassword, set credentialKeysecure: true explicitly in dev (default is false when not production)sameSite: 'none' requires secure: truecreateAuth<User>({ ... })id: string | numberwindow: 0 — too strict for real-world useMemoryRateLimitStore resets on process restart and is per-processWhen 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.
sessionFields: ['email', 'name', 'accessToken', 'refreshToken', 'expiresAt'],
Store expiresAt (Unix seconds) at login time: Math.floor(Date.now() / 1000) + tokens.expires_in.
const REFRESH_BUFFER_SECONDS = 60;
async function ensureFreshToken() {
const session = auth();
const user = await session.user();
if (!user) return null;
const now = Math.floor(Date.now() / 1000);
if (user.expiresAt > now + REFRESH_BUFFER_SECONDS) return user; // still fresh
// Refresh the token
const tokens = await refreshAccessToken(user.refreshToken);
if (!tokens) { await session.logout(); return null; } // refresh failed
// Update session cookie with new tokens
const updated = { ...user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: now + tokens.expiresIn };
await session.login(updated);
return updated;
}
Key point: auth().login() replaces the session cookie — calling it with updated tokens IS the refresh mechanism.
expiresAt before making API calls, not after a 401logout() and redirect to loginWhen 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.
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.
CREATE TABLE login_sessions (
id TEXT PRIMARY KEY, -- random lookup key
token_hash TEXT NOT NULL, -- HMAC-SHA256 of the validation token
user_id TEXT NOT NULL, -- user who authenticated
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
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).
import { generateToken, signData, timingSafeEqual } from 'ideal-auth';
const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
const secret = process.env.TRANSFER_TOKEN_SECRET!; // separate from IDEAL_AUTH_SECRET
// After successful authentication on central app
async function createTransferToken(userId: string) {
const id = generateToken(20); // 40 hex chars — lookup key
const token = generateToken(32); // 64 hex chars — validation secret
const tokenHash = signData(token, secret);
await db.insert(loginSessions).values({ id, tokenHash, userId });
return { id, token }; // both included in redirect URL
}
// On tenant callback endpoint
async function validateTransferToken(id: string, token: string) {
const deleted = await db.delete(loginSessions).where(eq(loginSessions.id, id))
.returning({ tokenHash: loginSessions.tokenHash, userId: loginSessions.userId, createdAt: loginSessions.createdAt });
if (deleted.length === 0) return null;
const row = deleted[0];
if (row.createdAt.getTime() < Date.now() - TOKEN_EXPIRY_MS) return null; // expired
const candidateHash = signData(token, secret);
if (!timingSafeEqual(candidateHash, row.tokenHash)) return null; // invalid token
return { userId: row.userId };
}
// Central app MUST validate callbackUrl before redirecting
const ALLOWED_TENANT_DOMAINS = process.env.ALLOWED_TENANT_DOMAINS!.split(',');
function validateTenantCallbackUrl(url: string | null): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') return null;
if (!ALLOWED_TENANT_DOMAINS.includes(parsed.host)) return null;
return url;
} catch { return null; }
}
attemptUserWith database (resolveUser):
const auth = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
attemptUser: async (credentials) => {
const data = await validateTransferToken(credentials.id, credentials.token);
if (!data) return null;
let user = await db.user.findUnique({ where: { id: data.userId } });
if (!user) user = await db.user.create({ data: { id: data.userId } });
return user;
},
});
Without database (sessionFields) — user info stored in cookie:
const auth = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
sessionFields: ['email', 'name', 'accessToken'],
attemptUser: async (credentials) => {
const data = await validateTransferToken(credentials.id, credentials.token);
if (!data) return null;
// Fetch profile from identity provider using the access token
const res = await fetch('https://identity.example.com/api/userinfo', {
headers: { Authorization: `Bearer ${data.accessToken}` },
});
if (!res.ok) return null;
const profile = await res.json();
return { id: profile.sub, email: profile.email, name: profile.name, accessToken: data.accessToken };
},
});
// user() returns { id, email, name, accessToken } from cookie — zero API calls after login
// GET /api/auth/callback?id=xxx&token=yyy&callbackUrl=/dashboard
const id = request.searchParams.get('id');
const token = request.searchParams.get('token');
if (!id || !token) redirect('/login-failed');
const session = auth();
const success = await session.attempt({ id, token });
if (success) redirect(safeRedirect(callbackUrl, '/dashboard'));
Tenant (no session) → redirect to central login with callbackUrl
Central login → validate callbackUrl against tenant allowlist → authenticate
→ createTransferToken(userId) → redirect to tenant callback with id + token
Tenant callback → validateTransferToken(id, token) → attemptUser → session created
id (lookup) and token (validation) — neither alone is usefultimingSafeEqual to compare HMAC hashes, preventing timing attacksTRANSFER_TOKEN_SECRET must be different from IDEAL_AUTH_SECRETTRANSFER_TOKEN_SECRETuserId is not a secret — the real protection is the HMAC'd token, one-time use, and short expiryWhen authenticating through a central identity provider (OAuth, OIDC, or custom login service), logging out requires a redirect chain that clears sessions on every domain.
Tenant: auth().logout() → clear local cookie → redirect to central /logout
Central: auth().logout() → clear central cookie → redirect to OIDC provider logout (if applicable)
Provider: clear provider session → redirect to post_logout_redirect_uri
Final: redirect back to tenant origin
export async function logoutAction() {
const session = auth();
const user = await session.user();
const idToken = user?.idToken; // needed for OIDC provider logout
await session.logout();
const logoutUrl = new URL(`${process.env.CENTRAL_LOGIN_URL}/logout`);
logoutUrl.searchParams.set('callbackUrl', process.env.NEXT_PUBLIC_APP_URL!);
if (idToken) logoutUrl.searchParams.set('id_token_hint', idToken);
redirect(logoutUrl.toString());
}
// Clear central session, then redirect to OIDC provider (or back to tenant if no provider)
const session = auth();
await session.logout();
// With OIDC provider:
const params = new URLSearchParams({ post_logout_redirect_uri: `${AUTH_URL}/logout/callback` });
if (idTokenHint) params.set('id_token_hint', idTokenHint);
redirect(`${OIDC_ISSUER_URL}/connect/logout?${params}`);
// Without OIDC provider:
redirect(callbackUrl);
auth().logout() only clears the current domain's cookie — cannot clear cookies on other domainscallbackUrl on central logout endpoint (same allowlist as login)id_token_hint for OIDC provider logout — without it, some providers show a confirmation pagelogout()sessions tableidToken in sessionFields if needed for OIDC logoutBefore deploying, verify:
IDEAL_AUTH_SECRET is set, 32+ chars, not in version controlNODE_ENV=production is setmaxAge is appropriate for your use casehttpOnly is forced (default — do not strip in cookie bridge)secure cookie flag is true in productionsameSite is lax (default) — only change if you understand the implicationsiatMs is checked against relevant timestampssafeRedirectProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub ramonmalcolm10/ideal-auth