Configuration
Environment variables
Section titled “Environment variables”| Variable | Description | Required |
|---|---|---|
IDEAL_AUTH_SECRET | Encryption secret for sessions and token signing. Must be at least 32 characters. Generate with bunx ideal-auth secret. | Yes |
ENCRYPTION_KEY | Encryption key for data at rest (TOTP secrets, access tokens). Generate with bunx ideal-auth encryption-key. | Only if using encrypt()/decrypt() |
Click "Generate" to create and copy Click "Generate" to create and copy Or use the CLI:
bunx ideal-auth secret # generates IDEAL_AUTH_SECRETbunx ideal-auth encryption-key # generates ENCRYPTION_KEYcreateAuth
Section titled “createAuth”createAuth is the main entry point for session authentication. It returns a factory function that produces a request-scoped AuthInstance.
import { createAuth, createHash } from 'ideal-auth';
const hash = createHash();
const auth = createAuth({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, session: { cookieName: 'my_session', maxAge: 60 * 60 * 24, // 1 day rememberMaxAge: 60 * 60 * 24 * 90, // 90 days cookie: { sameSite: 'strict', domain: '.example.com', }, }, resolveUser: async (id) => db.user.findUnique({ where: { id } }), resolveUserByCredentials: async (creds) => db.user.findUnique({ where: { email: creds.email } }), hash, credentialKey: 'password', passwordField: 'password',});Config options
Section titled “Config options”| Option | Type | Default | Description |
|---|---|---|---|
secret | string | — | Required. Encryption secret, at least 32 characters. Typically process.env.IDEAL_AUTH_SECRET. |
cookie | CookieBridge | — | Required. Object with get, set, and delete methods for your framework’s cookie API. See Getting Started. |
session | object | See below | Session configuration (cookie name, TTL, cookie options). |
resolveUser | (id: string) => Promise<TUser | null | undefined> | — | Looks up a user by ID. Called when rehydrating a session from an encrypted cookie. Required unless sessionFields is provided. The return type defines what user() exposes — only select safe fields (no password). |
sessionFields | (keyof TUser & string)[] | — | Fields from the user object to store in the session cookie. When provided, user() returns only id + the declared fields. Required unless resolveUser is provided. Cannot be used together with resolveUser. |
resolveUserByCredentials | (credentials) => Promise<AnyUser | null | undefined> | undefined | Looks up a user by credentials (with the password field stripped). Used by attempt() in the Laravel-style flow. Can return any shape — only needs id + the passwordField for verification. Does not need to match TUser. |
hash | HashInstance | undefined | A createHash() instance. Required if using resolveUserByCredentials with attempt(). |
credentialKey | string | 'password' | The key in the credentials object that holds the plaintext password. attempt() strips this key before passing credentials to resolveUserByCredentials. |
passwordField | string | 'password' | The field on the user object that holds the stored bcrypt hash. Used by attempt() to verify the password. |
attemptUser | (credentials: Record<string, any>) => Promise<TUser | null> | undefined | Escape hatch for full control over credential verification. If provided, attempt() delegates entirely to this function instead of using hash + resolveUserByCredentials. |
CookieBridge interface
Section titled “CookieBridge interface”The cookie bridge decouples ideal-auth from any specific framework. It has three methods:
interface 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;}All three methods can be synchronous or asynchronous.
attempt() strategies
Section titled “attempt() strategies”There are two ways to configure attempt():
Laravel-style (recommended): Provide hash, resolveUserByCredentials, and optionally credentialKey and passwordField. The attempt() method will strip the password from credentials, look up the user, and verify the hash automatically.
const auth = createAuth({ // ... hash: createHash(), resolveUserByCredentials: async (creds) => db.user.findUnique({ where: { email: creds.email } }), credentialKey: 'password', // default passwordField: 'password', // default});
// Usage: auth().attempt({ email, password })Escape hatch: Provide attemptUser for complete control. This takes precedence over the Laravel-style config.
const auth = createAuth({ // ... attemptUser: async (credentials) => { const user = await db.user.findUnique({ where: { email: credentials.email }, }); if (!user) return null; const valid = await customVerify(credentials.password, user.hash); return valid ? user : null; },});Session modes
Section titled “Session modes”createAuth supports two mutually exclusive modes for how user() resolves the authenticated user. Provide exactly one of resolveUser or sessionFields. TypeScript will show an error in your editor if you provide both or neither.
Database-backed (resolveUser): The session cookie stores only the user ID. Every user() call invokes resolveUser(id) to fetch the user from your database. The return type of resolveUser defines what user() exposes — only select safe fields.
// TUser is the safe type — no password, no sensitive fieldstype SafeUser = { id: string; email: string; name: string; role: string;};
const auth = createAuth<SafeUser>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge,
// Returns SafeUser — this is what user() exposes resolveUser: async (id) => db.user.findFirst({ where: { id }, columns: { id: true, email: true, name: true, role: true }, // no password }),
// Can return any shape — only needs id + password field for verification // Does NOT need to match SafeUser resolveUserByCredentials: async (creds) => db.user.findFirst({ where: { email: creds.email }, }), hash,});
const user = await auth().user();// Type: SafeUser | null — no password, safe to pass to clientCookie-backed (sessionFields): The session cookie stores the user ID plus the declared fields. user() returns TUser | null. Since TUser should not include sensitive fields like password, the return type is already safe.
// TUser is the session user — no passwordtype SessionUser = { id: string; email: string; name: string; role: string;};
const auth = createAuth<SessionUser>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, sessionFields: ['email', 'name', 'role'], // Can return any shape — only needs id + password field for verification resolveUserByCredentials: async (creds) => db.user.findFirst({ where: { email: creds.email } }), hash,});
const user = await auth().user();// Type: SessionUser | null — no password, safe to pass to clientPassword safety
Section titled “Password safety”ideal-auth strips the password field from the cached user after attempt() succeeds. This means even on the same request, user() will not include the password. However, you should still follow these practices:
-
resolveUsermode: TypeTUseras the safe type (without password). Only select non-sensitive columns in yourresolveUserquery.resolveUserByCredentialscan return the full database row — it’s only used internally for hash verification. -
sessionFieldsmode: Only declare non-sensitive fields. Thepasswordfield is never stored in the cookie and is excluded from theuser()return type. -
Same-request
user()afterattempt(): IfresolveUserByCredentialsreturns fewer fields thanresolveUser(e.g., onlyid+email+passwordfor verification), callinguser()on the same request afterattempt()returns those fields minus the password. Fields likenameorrolewill be missing until the next request, whenresolveUseris called. To avoid this, either return the same fields from both callbacks, or don’t calluser()on the same request asattempt()(redirect after login instead). -
Never pass
user()directly to the client without filtering. Even with correct types, always be explicit about what you send:// Server Component (Next.js)const user = await auth().user();// Only pass what the client needs — never spread the full userreturn <Dashboard user={{ name: user.name, email: user.email }} />;
resolveUser | sessionFields | |
|---|---|---|
user() reads from | Database | Cookie |
| DB calls after login | Every request | Zero |
| Data freshness | Always current | Snapshot at login |
loginById() | Supported | Not supported (throws) |
| Cookie size | Minimal (~100 bytes) | Depends on stored fields (~4KB limit) |
| Best for | Real-time role/permission changes | Performance, stateless, no-DB tenants |
Session config
Section titled “Session config”The session object on createAuth controls cookie naming, TTL, and cookie attributes.
| Option | Type | Default | Description |
|---|---|---|---|
cookieName | string | 'ideal_session' | Name of the session cookie. |
maxAge | number | 604800 (7 days) | Session lifetime in seconds. Used when remember is not set or remember: false. |
rememberMaxAge | number | 2592000 (30 days) | Extended session lifetime in seconds. Used when login(user, { remember: true }) is called. |
cookie | Partial<ConfigurableCookieOptions> | {} | Additional cookie attributes (see table below). |
autoTouch | boolean | false | Automatically extend the session when check(), user(), or id() is called and the session is past the halfway point. Enable for frameworks where every route can write cookies (Express, Hono). Leave disabled for Next.js (Server Components can’t write cookies). |
Cookie options
Section titled “Cookie options”The session.cookie object accepts any cookie attribute except httpOnly, which is always forced to true and cannot be overridden.
| Option | Type | Default | Description |
|---|---|---|---|
secure | boolean | true in production | Whether the cookie is sent only over HTTPS. Defaults to true when NODE_ENV === 'production'. Can be overridden. |
sameSite | 'lax' | 'strict' | 'none' | 'lax' | SameSite attribute for CSRF protection. |
path | string | '/' | URL path scope for the cookie. |
domain | string | undefined | Domain scope for the cookie. |
Session payload
Section titled “Session payload”The encrypted session cookie contains a SessionPayload:
interface SessionPayload { uid: string; // User ID (always stored as string) iat: number; // Issued-at timestamp (Unix seconds) exp: number; // Expiration timestamp (Unix seconds) data?: Record<string, unknown>; // Additional fields (when using sessionFields)}The data field is populated when sessionFields is configured. It contains only the fields you declared — nothing else from the user object leaks into the cookie.
The payload is encrypted using iron-session (AES-256-CBC + HMAC-SHA-256). It cannot be read or tampered with on the client.
Session extension
Section titled “Session extension”Session cookies have a fixed expiry set at login time. There are two ways to extend sessions for active users:
Option 1: autoTouch (automatic)
Section titled “Option 1: autoTouch (automatic)”Enable autoTouch for frameworks where every route can write cookies (Express, Hono, Elysia). When check(), user(), or id() is called and the session is past the halfway point of its lifetime, the cookie is automatically re-sealed with a fresh expiry.
Global (config level) — every auth() call has autoTouch enabled:
const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, session: { autoTouch: true }, resolveUser: async (id) => db.user.findFirst({ where: { id } }),});Per-request — pass autoTouch when calling auth(). Overrides the config default. Ideal for Next.js where middleware can write cookies but Server Components can’t:
// Next.js middleware — opt in to autoTouch for this requestconst session = auth({ autoTouch: true });await session.check(); // auto-extends if past halfway
// Next.js Server Component — default, read-onlyconst session = auth();await session.check(); // no cookie writes, safe in Server ComponentsOption 2: touch() (manual)
Section titled “Option 2: touch() (manual)”Call touch() explicitly in middleware or route handlers. When autoTouch is disabled (default), touch() only re-seals past the halfway point — safe to call on every request without performance concerns. When autoTouch is enabled, touch() re-seals immediately.
// Next.js middleware or Express/Hono route handlerconst session = auth();if (await session.check()) { await session.touch();}Behavior summary
Section titled “Behavior summary”autoTouch: false (default) | autoTouch: true | |
|---|---|---|
check() / user() / id() | Read-only, no cookie writes | Auto-reseals past halfway |
touch() | Reseals past halfway only | Reseals immediately |
| Best for | Next.js (Server Components can’t write cookies) | Express, Hono, Elysia, SvelteKit |
touch() preserves the original iat (issued-at) — passwordChangedAt invalidation continues to work after a touch. The session’s maxAge is also preserved: remember-me sessions extend by 30 days, standard sessions by 7 days.
Password hashing
Section titled “Password hashing”Password hashing is only needed when you verify credentials yourself — specifically when using attempt() with the Laravel-style hash + resolveUserByCredentials flow. You do not need a HashInstance or bcryptjs if you:
- Use
attemptUser(the escape hatch) — you handle verification in the callback - Use
login(user)directly — user is already authenticated (e.g., via OAuth, cross-domain transfer token, or an external API) - Use
sessionFieldswithout any credential verification
ideal-auth accepts any object that implements the HashInstance interface:
interface HashInstance { make(password: string): Promise<string>; verify(password: string, hash: string): Promise<boolean>;}You can use the built-in createHash() (bcryptjs) or bring your own implementation.
createHash (bcryptjs)
Section titled “createHash (bcryptjs)”Configures bcrypt password hashing via bcryptjs. Automatically applies SHA-256 prehash for passwords exceeding 72 bytes (bcrypt’s input limit).
import { createHash } from 'ideal-auth';
const hash = createHash({ rounds: 14 });
const hashed = await hash.make('my-password');const valid = await hash.verify('my-password', hashed);| Option | Type | Default | Description |
|---|---|---|---|
rounds | number | 12 | bcrypt cost factor (salt rounds). Higher values are slower but more secure. Each increment roughly doubles the computation time. |
Custom hash (bring your own)
Section titled “Custom hash (bring your own)”If your runtime provides built-in password hashing (e.g., Bun) or you prefer a different algorithm (e.g., argon2), pass your own HashInstance instead of using createHash(). No bcryptjs dependency needed.
import { prehash } from 'ideal-auth';import type { HashInstance } 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),};prehash applies SHA-256 for passwords exceeding bcrypt’s 72-byte input limit, preventing silent truncation. It returns the password unchanged if it’s within the limit.
import type { HashInstance } from 'ideal-auth';
const hash: HashInstance = { make: (password) => Bun.password.hash(password, { algorithm: 'argon2id', memoryCost: 65536, // 64 MB timeCost: 2, }), verify: (password, hash) => Bun.password.verify(password, hash),};import argon2 from 'argon2';import type { HashInstance } from 'ideal-auth';
const hash: HashInstance = { make: (password) => argon2.hash(password), verify: (password, hash) => argon2.verify(hash, password),};Then pass it to createAuth:
const auth = createAuth({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, resolveUser: async (id) => db.user.findUnique({ where: { id } }), hash, // your custom HashInstance resolveUserByCredentials: async (creds) => db.user.findUnique({ where: { email: creds.email } }),});createTokenVerifier
Section titled “createTokenVerifier”Creates signed, expiring tokens for flows like password reset, email verification, and magic links. Tokens are HMAC-signed and contain the user ID and timestamps.
import { createTokenVerifier } from 'ideal-auth';
const verifier = createTokenVerifier({ secret: process.env.IDEAL_AUTH_SECRET!, expiryMs: 1000 * 60 * 15, // 15 minutes});
const token = verifier.createToken(userId);const result = verifier.verifyToken(token);// result: { userId: string, iatMs: number } | nullConfig options
Section titled “Config options”| Option | Type | Default | Description |
|---|---|---|---|
secret | string | — | Required. Signing secret, at least 32 characters. |
expiryMs | number | 3600000 (1 hour) | Token lifetime in milliseconds. |
Return value of verifyToken
Section titled “Return value of verifyToken”Returns null if the token is invalid or expired. Otherwise returns:
| Field | Type | Description |
|---|---|---|
userId | string | The user ID embedded in the token. |
iatMs | number | Issued-at time in milliseconds (not seconds). Useful for single-use checks (e.g., reject if token was issued before the last password change). |
createTOTP
Section titled “createTOTP”Creates a TOTP (Time-based One-Time Password) instance compliant with RFC 6238. Used for two-factor authentication.
import { createTOTP } from 'ideal-auth';
const totp = createTOTP({ digits: 6, period: 30, window: 1,});
const secret = totp.generateSecret();const uri = totp.generateQrUri({ secret, issuer: 'MyApp', account: 'user@example.com',});const valid = totp.verify('123456', secret);Config options
Section titled “Config options”| Option | Type | Default | Description |
|---|---|---|---|
digits | number | 6 | Number of digits in the generated code. |
period | number | 30 | Time step in seconds. A new code is generated every period seconds. |
window | number | 1 | Number of time steps to check before and after the current step. A window of 1 accepts codes from the previous, current, and next period (90-second effective window with a 30-second period). |
Methods
Section titled “Methods”| Method | Returns | Description |
|---|---|---|
generateSecret() | string | Generates a random Base32-encoded secret (20 bytes). |
generateQrUri({ secret, issuer, account }) | string | Returns an otpauth:// URI suitable for QR code generation. |
verify(token, secret) | boolean | Verifies a TOTP code against the secret using timing-safe comparison. |
generateRecoveryCodes
Section titled “generateRecoveryCodes”Generates a set of hashed recovery codes for 2FA backup. Returns both the plaintext codes (to display to the user once) and the hashed versions (to store in your database).
import { createHash, generateRecoveryCodes, verifyRecoveryCode } from 'ideal-auth';
const hash = createHash();
// Generate codesconst { codes, hashed } = await generateRecoveryCodes(hash, 8);// codes: ['a1b2c3d4-e5f6g7h8', ...] — show to user// hashed: ['$2a$12$...', ...] — store in database
// Verify a codeconst result = await verifyRecoveryCode(userInput, storedHashes, hash);// result: { valid: boolean, remaining: string[] }| Parameter | Type | Default | Description |
|---|---|---|---|
hashInstance | HashInstance | — | Required. A createHash() instance for hashing the codes. |
count | number | 8 | Number of recovery codes to generate. |
createRateLimiter
Section titled “createRateLimiter”Creates a rate limiter with a sliding window. Useful for protecting login endpoints from brute-force attacks.
import { createRateLimiter } from 'ideal-auth';
const limiter = createRateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000, // 15 minutes});
const result = await limiter.attempt('login:user@example.com');// result: { allowed: boolean, remaining: number, resetAt: Date }
if (!result.allowed) { throw new Error('Too many attempts. Try again later.');}Config options
Section titled “Config options”| Option | Type | Default | Description |
|---|---|---|---|
maxAttempts | number | — | Required. Maximum number of attempts allowed within the window. |
windowMs | number | — | Required. Time window in milliseconds. The counter resets after this period. |
store | RateLimitStore | MemoryRateLimitStore | Pluggable storage backend. Defaults to an in-memory store. Implement the RateLimitStore interface for Redis or database-backed stores. |
Custom store interface
Section titled “Custom store interface”interface RateLimitStore { increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>; reset(key: string): Promise<void>;}Methods
Section titled “Methods”| Method | Returns | Description |
|---|---|---|
attempt(key) | Promise<RateLimitResult> | Increments the counter for the given key and returns whether the attempt is allowed. |
reset(key) | Promise<void> | Resets the counter for the given key (e.g., after a successful login). |
Crypto utilities
Section titled “Crypto utilities”Low-level cryptographic functions exported for use in custom flows.
import { encrypt, decrypt, signData, verifySignature, timingSafeEqual, generateToken,} from 'ideal-auth';| Function | Signature | Description |
|---|---|---|
encrypt(data, secret) | (string, string) => string | Encrypts a string using the provided secret. |
decrypt(data, secret) | (string, string) => string | Decrypts a string previously encrypted with encrypt. |
signData(data, secret) | (string, string) => string | Creates an HMAC signature for the given data. |
verifySignature(data, signature, secret) | (string, string, string) => boolean | Verifies an HMAC signature. |
timingSafeEqual(a, b) | (string, string) => boolean | Constant-time string comparison to prevent timing attacks. |
generateToken(bytes?) | (number?) => string | Generates a cryptographically random hex token. Default is 32 bytes (64 hex characters). |
Full configuration examples
Section titled “Full configuration examples”import { createAuth, createHash, createTokenVerifier, createTOTP, createRateLimiter,} from 'ideal-auth';import { cookies } from 'next/headers';import { db } from './db';
// Cookie bridge (Next.js App Router)const cookieBridge = { async get(name: string) { return (await cookies()).get(name)?.value; }, async set(name: string, value: string, options: any) { (await cookies()).set(name, value, options); }, async delete(name: string) { (await cookies()).delete(name); },};
// Password hashingexport const hash = createHash({ rounds: 12 });
// Session auth — user() fetches from database on every requestexport const auth = createAuth({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, resolveUser: (id) => db.user.findUnique({ where: { id } }), resolveUserByCredentials: (creds) => db.user.findUnique({ where: { email: creds.email } }), hash,});
// Token verifier (password reset, email verification)export const tokenVerifier = createTokenVerifier({ secret: process.env.IDEAL_AUTH_SECRET!, expiryMs: 1000 * 60 * 60, // 1 hour});
// TOTP for 2FAexport const totp = createTOTP();
// Rate limiter for loginexport const loginLimiter = createRateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000, // 15 minutes});import { createAuth, createHash, createRateLimiter,} from 'ideal-auth';import { cookies } from 'next/headers';import { db } from './db';
// TUser is the session user — no passwordtype User = { id: string; email: string; name: string; role: string;};
const cookieBridge = { async get(name: string) { return (await cookies()).get(name)?.value; }, async set(name: string, value: string, options: any) { (await cookies()).set(name, value, options); }, async delete(name: string) { (await cookies()).delete(name); },};
export const hash = createHash({ rounds: 12 });
// Session auth — user() reads from cookie, zero database calls after loginexport const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, sessionFields: ['email', 'name', 'role'], resolveUserByCredentials: (creds) => db.user.findUnique({ where: { email: creds.email } }), hash,});
export const loginLimiter = createRateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000,});import { createAuth } from 'ideal-auth';
type User = { id: string; email: string; name: string; accessToken: string;};
const cookieBridge = { async get(name: string) { return (await cookies()).get(name)?.value; }, async set(name: string, value: string, options: any) { (await cookies()).set(name, value, options); }, async delete(name: string) { (await cookies()).delete(name); },};
// No database — user info comes from OIDC and is stored in the cookieexport const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, sessionFields: ['email', 'name', 'accessToken'], attemptUser: async (credentials) => { // Validate the cross-domain transfer token const data = await validateTransferToken(credentials.id, credentials.token); if (!data) return null;
// Fetch user profile from identity provider 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, }; },});