| name | ideal-auth |
| 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, passkey, passkeys, WebAuthn, passwordless, biometric, fingerprint. |
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.
ideal-auth Complete Reference
Overview
Auth primitives for the JS ecosystem. Zero framework dependencies. Inspired by Laravel's Auth and Hash facades.
- Sessions: Stateless, encrypted cookies via iron-session (AES-256-CBC + HMAC integrity)
- Passwords: bcrypt via bcryptjs with SHA-256 prehash for passwords > 72 bytes
- Tokens: HMAC-SHA256 signed, expiring tokens for password reset, email verification, magic links
- TOTP: RFC 6238 two-factor authentication with recovery codes
- Rate Limiting: Pluggable store (in-memory default, Redis/DB for production)
- Crypto: AES-256-GCM encryption, HMAC signing, timing-safe comparison
- Cookie Bridge: 3-function adapter — works with any framework
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)
API Reference
createAuth(config): (options?) => AuthInstance
Returns 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();
const session = auth({ autoTouch: true });
AuthConfig
type AuthConfig<TUser> =
| {
resolveUser: (id: string) => Promise<TUser | null | undefined>;
sessionFields?: never;
resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<AnyUser | null | undefined>;
hash?: HashInstance;
attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
}
| {
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 Modes
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(),
resolveUser: async (id) => db.user.findFirst({
where: { id },
columns: { id: true, email: true, name: true, role: true },
}),
resolveUserByCredentials: async (creds) => db.user.findFirst({
where: { email: creds.email },
}),
hash,
});
const user = await auth().user();
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 };
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();
Key rules:
TUser is the session user type — what user() returns. Do not include password.
- Provide exactly one of
resolveUser or sessionFields — TypeScript errors if both or neither
resolveUserByCredentials returns AnyUser — doesn't need to match TUser, only needs id + password field
- Password is stripped from the cached user after
attempt() — even same-request user() won't expose it
- Cookie limit is ~4KB — store basic fields only
loginById() requires resolveUser — throws with sessionFields
- Never pass
user() directly to the client — always pick the specific fields you need
AuthInstance Methods
| 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. |
LoginOptions
type LoginOptions = {
remember?: boolean;
};
Session Extension
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:
const session = auth({ autoTouch: true });
await session.check();
const session = auth();
await session.check();
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 Modes
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.
const auth = createAuth({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
hash,
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
resolveUserByCredentials: async (creds) => {
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
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 });
const hashed = await hash.make('password');
const valid = await hash.verify('password', hashed);
Custom hash (bring your own) — use your runtime's native hashing or a different algorithm. No bcryptjs needed.
import { prehash } from 'ideal-auth';
const hash: HashInstance = {
make: (password) => Bun.password.hash(prehash(password), { algorithm: 'bcrypt', cost: 12 }),
verify: (password, hash) => Bun.password.verify(prehash(password), hash),
};
const hash: HashInstance = {
make: (password) => Bun.password.hash(password, { algorithm: 'argon2id', memoryCost: 65536, timeCost: 2 }),
verify: (password, hash) => Bun.password.verify(password, hash),
};
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,
resolveUserByCredentials: async (creds) =>
db.user.findUnique({ where: { email: creds.email } }),
});
createTokenVerifier(config): TokenVerifierInstance
Signed, expiring tokens for password resets, email verification, magic links, invites. Create one instance per use case with its own purpose/expiry.
type TokenVerifierConfig = {
secret: string;
purpose: string;
expiryMs?: number;
};
type TokenVerifierInstance = {
createToken(userId: string): string;
verifyToken(token: string): { userId: string; iatMs: number } | null;
};
Token format: encodedUserId.randomId.issuedAtMs.expiryMs.signature (HMAC-SHA256 signed). The purpose is mixed into the HMAC signature, so a token only verifies against a verifier configured with the same purpose.
Important: Tokens are stateless. Use iatMs to reject tokens issued before a relevant event (e.g., password change). Use one secret with a distinct purpose per use case — tokens aren't interchangeable across purposes, so cross-flow replay is impossible even when verifiers share a secret.
createTOTP(config?): TOTPInstance
RFC 6238 TOTP generation and verification.
type TOTPConfig = {
digits?: number;
period?: number;
window?: number;
};
type TOTPInstance = {
generateSecret(): string;
generateQrUri(opts: { secret: string; issuer: string; account: string }): string;
verify(token: string, secret: string, lastUsedCounter: number | null): { valid: boolean; counter: number | null };
};
verify has built-in enforced replay protection: lastUsedCounter is the persisted time-step counter of the last successful verification (null for first use), and codes at or before it are rejected. On success, persist the returned counter (e.g. a totpLastUsedCounter field) and pass it back on the next verification. Tokens are normalized before checking (whitespace stripped; must be exactly the configured digit length).
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);
const { valid, remaining } = await verifyRecoveryCode(code, storedHashes, hash);
createRateLimiter(config): RateLimiterInstance
type RateLimiterConfig = {
maxAttempts: number;
windowMs: number;
store?: RateLimitStore;
};
type RateLimitResult = {
allowed: boolean;
remaining: number;
resetAt: Date;
};
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>;
}
Crypto Utilities
All use node:crypto — no third-party dependencies.
import {
generateToken,
signData,
verifySignature,
encrypt,
decrypt,
timingSafeEqual,
} from 'ideal-auth';
const token = generateToken();
const short = generateToken(16);
const sig = signData('user:123:reset', secret);
const valid = verifySignature('user:123:reset', sig, secret);
const encrypted = await encrypt('sensitive data', secret);
const decrypted = await decrypt(encrypted, secret);
timingSafeEqual('abc', 'abc');
Key Types
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;
};
type ConfigurableCookieOptions = Omit<CookieOptions, 'httpOnly'>;
type SessionPayload = {
uid: string;
iat: number;
exp: number;
};
Cookie Bridge Patterns
Next.js (App Router)
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:
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:
'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:
'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:
'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:
'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):
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:
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:
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:
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 });
}
}
Edge Runtime: Use export const runtime = 'nodejs' in any Route Handler that calls auth().
SvelteKit
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:
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):
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):
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):
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.
Express
Requires cookie-parser: bun add cookie-parser + bun add -D @types/cookie-parser @types/express
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:
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:
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);
Auth middleware:
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:
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:
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.
Hono
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:
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:
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.
Nuxt
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:
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:
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.
TanStack Start
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:
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:
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:
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.
Elysia
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:
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.
Common Auth Flows
Password Reset
import { createTokenVerifier, createHash } from 'ideal-auth';
const passwordReset = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
purpose: 'password-reset',
expiryMs: 60 * 60 * 1000,
});
const token = passwordReset.createToken(user.id);
await sendEmail(user.email, `https://app.com/reset/${token}`);
const result = passwordReset.verifyToken(token);
if (!result) throw new Error('Invalid or expired token');
if (result.iatMs < user.passwordChangedAt) {
throw new Error('Token already used');
}
const hash = createHash();
await db.user.update({
where: { id: result.userId },
data: {
password: await hash.make(newPassword),
passwordChangedAt: Date.now(),
},
});
Email Verification
const emailVerification = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
purpose: 'email-verification',
expiryMs: 24 * 60 * 60 * 1000,
});
const token = emailVerification.createToken(user.id);
await sendEmail(user.email, `https://app.com/verify/${token}`);
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() },
});
Two-Factor Authentication (TOTP)
Setup phase:
import { createTOTP, createHash, encrypt, generateRecoveryCodes } from 'ideal-auth';
const totp = createTOTP();
const hash = createHash();
const secret = totp.generateSecret();
const uri = totp.generateQrUri({
secret,
issuer: 'MyApp',
account: user.email,
});
const setupResult = totp.verify(codeFromAuthenticator, secret, null);
if (!setupResult.valid) {
throw new Error('Invalid setup code');
}
await db.user.update({
where: { id: user.id },
data: {
totpSecret: await encrypt(secret, process.env.ENCRYPTION_KEY!),
totpEnabled: true,
totpLastUsedCounter: setupResult.counter,
},
});
const { codes, hashed } = await generateRecoveryCodes(hash, 8);
await db.user.update({
where: { id: user.id },
data: { recoveryCodes: hashed },
});
Login with 2FA:
import { decrypt } from 'ideal-auth';
if (user.totpEnabled) {
const decryptedSecret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY!);
const result = totp.verify(codeFromUser, decryptedSecret, user.totpLastUsedCounter);
if (!result.valid) {
throw new Error('Invalid 2FA code');
}
await db.user.update({
where: { id: user.id },
data: { totpLastUsedCounter: result.counter },
});
}
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);
}
Rate-Limited Login (Next.js example)
'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,
});
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('/');
}
Remember Me
await auth().login(user, { remember: false });
await auth().login(user);
await auth().login(user, { remember: true });
Passkeys (WebAuthn)
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.
Flow
Registration: browser creates key pair → store public key in DB
Authentication: server sends challenge → browser signs it → server verifies → auth().login(user)
After WebAuthn verification, create session with ideal-auth
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) {
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);
}
Key points
- Install:
bun add @simplewebauthn/server @simplewebauthn/browser
- Store challenges in httpOnly cookies (5-min expiry), not client-side
- Always update the signature counter after authentication
- Set
rpID to root domain (e.g., example.com) — works across subdomains
userVerification: 'preferred' for most apps, 'required' for high-security
- Passkeys with user verification can replace 2FA (biometric/PIN is the second factor)
- Rate limit the options and verify endpoints
Open Redirect Prevention
Always validate redirect URLs after login. Never redirect to user-supplied absolute URLs.
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';
redirect(safeRedirect(redirectTo, '/dashboard'));
Security Rules
Always enforced by ideal-auth:
httpOnly: true on session cookies — forced at runtime, cannot be overridden
secure: true when NODE_ENV === 'production'
sameSite: 'lax' by default
path: '/' by default
- Secret must be 32+ characters — throws at startup if shorter
- SHA-256 prehash for passwords > 72 bytes (prevents silent bcrypt truncation)
- Timing-safe comparison for all secret/signature/TOTP operations
- Token verifiers require a
purpose (mixed into the signature) — throws at creation without it, blocks cross-flow token replay
- TOTP replay protection —
verify rejects codes at or before the lastUsedCounter you pass in
Your responsibility:
- CSRF protection (framework-dependent — see each framework section)
- Open redirect prevention (use
safeRedirect)
- Password minimum length enforcement (NIST recommends 8+)
- Rate limiting on login, registration, and password reset endpoints
- Using a persistent rate limit store in production (not in-memory)
- Encrypting TOTP secrets at rest
- Persisting the TOTP
counter after each successful verification and passing it back as lastUsedCounter
- Checking
iatMs on tokens to prevent reuse after password change
- Using a distinct
purpose per token verifier use case
- Setting
NODE_ENV=production in production
- Never logging passwords
- Using parameterized queries/ORM (SQL injection prevention)
- Content sanitization (XSS prevention)
CSRF by framework:
| 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 |
Configuration Defaults
| 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 |
Troubleshooting
Session not persisting
- Next.js 15+: Ensure
cookies() is awaited in the cookie bridge
- Cookie bridge: Ensure
set() passes all three args (name, value, options) to the framework
- Cookie path: Default is
'/' — if overridden, ensure it covers all routes
Login works in dev but not production
IDEAL_AUTH_SECRET must be set in production env vars
- Secret must be identical across all server instances/deploys
NODE_ENV=production must be set (controls secure cookie flag)
- HTTPS must be enabled (secure cookies only sent over HTTPS)
attempt() always returns false
- Check
resolveUserByCredentials returns the user object (not null)
- Ensure user has a
password field with a bcrypt hash (starts with $2a$ or $2b$)
- If your password column is named differently, set
passwordField
- If your credential key is not
password, set credentialKey
Cookie not set on localhost
- Don't set
secure: true explicitly in dev (default is false when not production)
sameSite: 'none' requires secure: true
- Frontend and API on different ports? Browser may block as third-party cookie
TypeScript errors with user type
- Pass your user type as generic:
createAuth<User>({ ... })
- User type must have
id: string | number
TOTP codes not verifying
- Server clock must be synced (use NTP)
- Don't set
window: 0 — too strict for real-world use
- Verify the TOTP secret round-trips correctly through storage
- A code at or before the persisted
lastUsedCounter is rejected (replay protection) — wait for the next code
- Ensure you persist the returned
counter on success and pass the stored value back on the next verify call
- Token must be exactly the configured digit length after whitespace is stripped
Rate limiter not working in production
MemoryRateLimitStore resets on process restart and is per-process
- Use Redis-backed store for serverless/multi-instance deployments
Token verifier returns null
- Same secret AND same
purpose must be used for creation and verification
- Check if token has expired (default: 1 hour)
- Secret rotation invalidates all outstanding tokens
Token Refresh (OAuth Access Tokens)
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.
Setup
sessionFields: ['email', 'name', 'accessToken', 'refreshToken', 'expiresAt'],
Store expiresAt (Unix seconds) at login time: Math.floor(Date.now() / 1000) + tokens.expires_in.
Proactive refresh pattern
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;
const tokens = await refreshAccessToken(user.refreshToken);
if (!tokens) { await session.logout(); return null; }
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.
Rules
- Check
expiresAt before making API calls, not after a 401
- Use a 60-second buffer before expiry to prevent race conditions
- When refresh fails,
logout() and redirect to login
- For refresh token rotation, prefer the new refresh token from the response
- In high-concurrency environments, use a lock/mutex to prevent multiple simultaneous refreshes
Multi-Tenant Cross-Domain Authentication
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.
When to use this pattern
- Tenants are on separate domains (not subdomains of a shared parent)
- Authentication happens on a central login page (identity domain)
- OAuth providers redirect to the central app, not individual tenants
- Cookies cannot be shared across tenant 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.
Database schema
CREATE TABLE login_sessions (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL,
user_id TEXT NOT NULL,
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).
Transfer token module
import { generateToken, signData, timingSafeEqual } from 'ideal-auth';
const TOKEN_EXPIRY_MS = 5 * 60 * 1000;
const secret = process.env.TRANSFER_TOKEN_SECRET!;
async function createTransferToken(userId: string) {
const id = generateToken(20);
const token = generateToken(32);
const tokenHash = signData(token, secret);
await db.insert(loginSessions).values({ id, tokenHash, userId });
return { id, token };
}
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;
const candidateHash = signData(token, secret);
if (!timingSafeEqual(candidateHash, row.tokenHash)) return null;
return { userId: row.userId };
}
Tenant domain allowlist (central app)
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; }
}
Tenant auth setup with attemptUser
With 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;
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 };
},
});
Tenant callback endpoint
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'));
Central login redirect flow
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
Security rules for multi-tenant auth
- Validate callbackUrl first: Central app must check against tenant domain allowlist before any redirect — this is the most critical check
- Two-part token: Separate
id (lookup) and token (validation) — neither alone is useful
- Timing-safe verification: Use
timingSafeEqual to compare HMAC hashes, preventing timing attacks
- Separate secret:
TRANSFER_TOKEN_SECRET must be different from IDEAL_AUTH_SECRET
- Same secret on all apps: Central and all tenants share
TRANSFER_TOKEN_SECRET
- One-time use: Delete token from DB immediately after validation
- 5-minute expiry: Reject tokens older than 5 minutes
- HTTPS required: Token travels in redirect URL — must be encrypted in transit
- Rate limit callback: 10 attempts/minute per IP on the callback endpoint
- Clean up expired tokens: Run a cron job hourly to delete old rows
- No payload encryption needed:
userId is not a secret — the real protection is the HMAC'd token, one-time use, and short expiry
- API endpoint auth: If tenants validate via API (not shared DB), protect with API key or mTLS
Federated Logout
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.
Flow
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
Tenant logout action
export async function logoutAction() {
const session = auth();
const user = await session.user();
const idToken = user?.idToken;
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());
}
Central logout endpoint
const session = auth();
await session.logout();
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}`);
redirect(callbackUrl);
Key rules
auth().logout() only clears the current domain's cookie — cannot clear cookies on other domains
- Each domain in the chain must be visited to clear its session
- Validate
callbackUrl on central logout endpoint (same allowlist as login)
- Use
id_token_hint for OIDC provider logout — without it, some providers show a confirmation page
- For token revocation, call the provider's revocation endpoint before
logout()
- If you need instant revocation across all domains, add a server-side
sessions table
- Store
idToken in sessionFields if needed for OIDC logout
Production Checklist
Before deploying, verify: