Skip to content

Configuration

VariableDescriptionRequired
IDEAL_AUTH_SECRETEncryption secret for sessions and token signing. Must be at least 32 characters. Generate with bunx ideal-auth secret.Yes
ENCRYPTION_KEYEncryption 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:

Terminal window
bunx ideal-auth secret # generates IDEAL_AUTH_SECRET
bunx ideal-auth encryption-key # generates ENCRYPTION_KEY

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',
});
OptionTypeDefaultDescription
secretstringRequired. Encryption secret, at least 32 characters. Typically process.env.IDEAL_AUTH_SECRET.
cookieCookieBridgeRequired. Object with get, set, and delete methods for your framework’s cookie API. See Getting Started.
sessionobjectSee belowSession 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>undefinedLooks 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.
hashHashInstanceundefinedA createHash() instance. Required if using resolveUserByCredentials with attempt().
credentialKeystring'password'The key in the credentials object that holds the plaintext password. attempt() strips this key before passing credentials to resolveUserByCredentials.
passwordFieldstring'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>undefinedEscape hatch for full control over credential verification. If provided, attempt() delegates entirely to this function instead of using hash + resolveUserByCredentials.

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.

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;
},
});

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 client

Cookie-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 client

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:

  • resolveUser mode: Type TUser as the safe type (without password). Only select non-sensitive columns in your resolveUser query. resolveUserByCredentials can return the full database row — it’s only used internally for hash verification.

  • sessionFields mode: Only declare non-sensitive fields. The password field is never stored in the cookie and is excluded from the user() return type.

  • Same-request user() after attempt(): If resolveUserByCredentials returns fewer fields than resolveUser (e.g., only id + email + password for verification), calling user() on the same request after attempt() returns those fields minus the password. Fields like name or role will be missing until the next request, when resolveUser is called. To avoid this, either return the same fields from both callbacks, or don’t call user() on the same request as attempt() (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 }} />;
resolveUsersessionFields
user() reads fromDatabaseCookie
DB calls after loginEvery requestZero
Data freshnessAlways currentSnapshot at login
loginById()SupportedNot supported (throws)
Cookie sizeMinimal (~100 bytes)Depends on stored fields (~4KB limit)
Best forReal-time role/permission changesPerformance, stateless, no-DB tenants

The session object on createAuth controls cookie naming, TTL, and cookie attributes.

OptionTypeDefaultDescription
cookieNamestring'ideal_session'Name of the session cookie.
maxAgenumber604800 (7 days)Session lifetime in seconds. Used when remember is not set or remember: false.
rememberMaxAgenumber2592000 (30 days)Extended session lifetime in seconds. Used when login(user, { remember: true }) is called.
cookiePartial<ConfigurableCookieOptions>{}Additional cookie attributes (see table below).
autoTouchbooleanfalseAutomatically 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).

The session.cookie object accepts any cookie attribute except httpOnly, which is always forced to true and cannot be overridden.

OptionTypeDefaultDescription
securebooleantrue in productionWhether 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.
pathstring'/'URL path scope for the cookie.
domainstringundefinedDomain scope for the cookie.

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 cookies have a fixed expiry set at login time. There are two ways to extend sessions for active users:

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 Components

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();
}
autoTouch: false (default)autoTouch: true
check() / user() / id()Read-only, no cookie writesAuto-reseals past halfway
touch()Reseals past halfway onlyReseals immediately
Best forNext.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 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 sessionFields without 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.

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);
OptionTypeDefaultDescription
roundsnumber12bcrypt cost factor (salt rounds). Higher values are slower but more secure. Each increment roughly doubles the computation time.

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.

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 } }),
});

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 } | null
OptionTypeDefaultDescription
secretstringRequired. Signing secret, at least 32 characters.
expiryMsnumber3600000 (1 hour)Token lifetime in milliseconds.

Returns null if the token is invalid or expired. Otherwise returns:

FieldTypeDescription
userIdstringThe user ID embedded in the token.
iatMsnumberIssued-at time in milliseconds (not seconds). Useful for single-use checks (e.g., reject if token was issued before the last password change).

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);
OptionTypeDefaultDescription
digitsnumber6Number of digits in the generated code.
periodnumber30Time step in seconds. A new code is generated every period seconds.
windownumber1Number 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).
MethodReturnsDescription
generateSecret()stringGenerates a random Base32-encoded secret (20 bytes).
generateQrUri({ secret, issuer, account })stringReturns an otpauth:// URI suitable for QR code generation.
verify(token, secret)booleanVerifies a TOTP code against the secret using timing-safe comparison.

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[] }
ParameterTypeDefaultDescription
hashInstanceHashInstanceRequired. A createHash() instance for hashing the codes.
countnumber8Number of recovery codes to generate.

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.');
}
OptionTypeDefaultDescription
maxAttemptsnumberRequired. Maximum number of attempts allowed within the window.
windowMsnumberRequired. Time window in milliseconds. The counter resets after this period.
storeRateLimitStoreMemoryRateLimitStorePluggable storage backend. Defaults to an in-memory store. Implement the RateLimitStore interface for Redis or database-backed stores.
interface RateLimitStore {
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
reset(key: string): Promise<void>;
}
MethodReturnsDescription
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).

Low-level cryptographic functions exported for use in custom flows.

import {
encrypt,
decrypt,
signData,
verifySignature,
timingSafeEqual,
generateToken,
} from 'ideal-auth';
FunctionSignatureDescription
encrypt(data, secret)(string, string) => stringEncrypts a string using the provided secret.
decrypt(data, secret)(string, string) => stringDecrypts a string previously encrypted with encrypt.
signData(data, secret)(string, string) => stringCreates an HMAC signature for the given data.
verifySignature(data, signature, secret)(string, string, string) => booleanVerifies an HMAC signature.
timingSafeEqual(a, b)(string, string) => booleanConstant-time string comparison to prevent timing attacks.
generateToken(bytes?)(number?) => stringGenerates a cryptographically random hex token. Default is 32 bytes (64 hex characters).

lib/auth.ts
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, email verification)
export const tokenVerifier = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
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
});