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 copyClick "Generate" to create and copyOr use the CLI:
bunx ideal-auth secret # generates IDEAL_AUTH_SECRET
bunx ideal-auth encryption-key # generates ENCRYPTION_KEYcreateAuth
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
| 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
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
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
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 fields
type 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 password
type 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
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 user return <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
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
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
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
Session cookies have a fixed expiry set at login time. There are two ways to extend sessions for active users:
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 request
const session = auth({ autoTouch: true });
await session.check(); // auto-extends if past halfway
// Next.js Server Component — default, read-only
const session = auth();
await session.check(); // no cookie writes, safe in Server ComponentsOption 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 handler
const session = auth();
if (await session.check()) {
await session.touch();
}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
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)
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)
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
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!,
purpose: 'password-reset',
expiryMs: 1000 * 60 * 15, // 15 minutes
});
const token = verifier.createToken(userId);
const result = verifier.verifyToken(token);
// result: { userId: string, iatMs: number } | nullConfig options
| Option | Type | Default | Description |
|---|---|---|---|
secret |
string |
— | Required. Signing secret, at least 32 characters. |
purpose |
string |
— | Required. Label for the flow (e.g. 'password-reset', 'email-verification', 'magic-link'). Mixed into the HMAC signature, so a token only verifies against a verifier with the same purpose — reuse one IDEAL_AUTH_SECRET across flows with distinct purposes. |
expiryMs |
number |
3600000 (1 hour) |
Token lifetime in milliseconds. |
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
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',
});
// Pass the persisted counter from the last successful verification,
// or null if the user has never verified a code.
const { valid, counter } = totp.verify('123456', secret, null);
// On success, persist `counter` (e.g. user.totpLastUsedCounter) and
// pass it back next time — codes at or before it are rejected.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
| 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, lastUsedCounter) |
{ valid: boolean; counter: number | null } |
Verifies a TOTP code against the secret using timing-safe comparison, with built-in replay protection. Codes at or before lastUsedCounter are rejected. On success, persist the returned counter and pass it back on the next verification. Tokens are normalized: whitespace is stripped, and anything that is not exactly digits digits is rejected. |
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 codes
const { codes, hashed } = await generateRecoveryCodes(hash, 8);
// codes: ['a1b2c3d4-e5f6g7h8', ...] — show to user
// hashed: ['$2a$12$...', ...] — store in database
// Verify a code
const 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
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
| 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
interface RateLimitStore {
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
reset(key: string): Promise<void>;
}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
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
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 hashing
export const hash = createHash({ rounds: 12 });
// Session auth — user() fetches from database on every request
export 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) — create one verifier per purpose,
// sharing the same secret
export const tokenVerifier = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
purpose: 'password-reset',
expiryMs: 1000 * 60 * 60, // 1 hour
});
// TOTP for 2FA
export const totp = createTOTP();
// Rate limiter for login
export 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 password
type 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 login
export 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 cookie
export 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,
};
},
});