Credentials & MFA
Add local email/password sign-in, multi-factor auth, and account lockout. Every block is additive and optional — each produces the same session as OAuth, transparent to protectRoute.
#Email & Password
The credentials block owns password hashing and single-use, hashed-at-rest verification / reset tokens. You own the user table through the hooks; the store ships in-memory, Postgres, and Neon flavors.
import { auth, createNeonCredentialStore } from '@absolutejs/auth';
const app = await auth<User>({
providersConfiguration: {},
credentials: {
credentialStore: createNeonCredentialStore(process.env.DATABASE_URL),
getUserByEmail: (email) => findUserByEmail(email),
onCreateCredentialUser: ({ email, ...extra }) => createUser({ email, ...extra }),
onSendEmail: ({ email, token, type }) => sendBrevoEmail(email, type, token),
passwordPolicy: { minLength: 12 },
requireEmailVerification: true
}
});#Routes
The credentials block mounts these routes, transparent to protectRoute:
| Method | Route | Description |
|---|---|---|
| POST | /auth/register | Create a local account. Body: { email, password, ...extraFields } |
| POST | /auth/login | Sign in with email and password. Body: { email, password } — returns { status } |
| POST | /auth/verify-email | Confirm an email with its verification token. Body: { token } |
| POST | /auth/verify-email/request | Request a new verification email. Body: { email } |
| POST | /auth/reset-password | Set a new password with a reset token. Body: { token, password } |
| POST | /auth/reset-password/request | Request a password-reset email. Body: { email } |
Bun.password (argon2id). Existing argon2id/bcrypt hashes verify as-is, so you can migrate a legacy user table with no rehash.#Multi-Factor Auth
import { auth, createNeonMfaStore } from '@absolutejs/auth';
await auth<User>({
providersConfiguration: {},
credentials: { /* … */ },
mfa: {
mfaStore: createNeonMfaStore(process.env.DATABASE_URL),
getUserId: (user) => user.sub,
// resolve the parked identity back into a user during a challenge:
getChallengeUser: (identity) => findUserByEmail(String(identity.email)),
issuer: 'Acme',
encryptionKey: process.env.MFA_ENCRYPTION_KEY // AES-GCM: encrypts the TOTP secret at rest
}
});
// With credentials + mfa set, login auto-parks the session until a factor is
// verified. Routes: /auth/mfa/totp/setup, /auth/mfa/totp/verify, /auth/mfa/challenge.#Step-Up Auth
Gate sensitive actions behind a fresh authentication. A token refresh does not count as recent auth, so destructive operations can demand a real login or MFA.
// auth() exposes a requireRecentAuth derive alongside protectRoute.
// The handler runs only if the session was established by a real authentication
// (login or MFA — NOT a token refresh) within the window; otherwise 401.
app.delete('/account', ({ requireRecentAuth }) =>
requireRecentAuth(5 * 60_000, (user) => deleteAccount(user))
);#Account Lockout
Per-identity attempt throttling on the login route. The store is in-memory, Postgres, or Redis — Redis gives atomic counters with native per-key TTL, shared across instances and self-expiring.
import {
auth,
createRedisLockoutStore,
type RedisLike
} from '@absolutejs/auth';
// RedisLike is a 3-method adapter — wrap ioredis / node-redis / Bun's RedisClient.
const redis: RedisLike = {
del: (key) => client.del(key),
get: (key) => client.get(key),
set: (key, value, ttlMs) => client.set(key, value, 'PX', String(ttlMs))
};
await auth<User>({
providersConfiguration: {},
credentials: { /* … */ },
lockout: {
lockoutStore: createRedisLockoutStore(redis, 'auth:lockout:'),
maxAttempts: 5,
windowMs: 15 * 60_000
}
});
// The credential login route returns 429 once an identity is locked. Redis gives
// atomic counters + native TTL (no cleanup job), shared across instances.#Bulk import & legacy hashes
importUser / importUsers migrate an Auth0, Cognito, or Firebase export in one pass.Bun.password. Legacy formats (Auth0 PBKDF2, Cognito SHA-256) are recognized by isLegacyHash and verified by the matching verifyAuth0Pbkdf2 / verifyCognitoSha256.rehashOnLogin and the first successful sign-in silently upgrades the stored hash to argon2id. No forced password reset, no public breach.import {
importUsers,
isLegacyHash,
rehashCredentialPassword,
verifyAuth0Pbkdf2,
verifyCognitoSha256
} from '@absolutejs/auth';
// Migrate an Auth0 / Cognito / Firebase export without forcing every user to
// reset their password. importUsers writes one row per record; argon2id and
// bcrypt hashes verify natively on next login. Legacy formats (Auth0 PBKDF2,
// Cognito SHA-256) are recognized by the isLegacyHash wrapper and verified by
// the matching legacy verifier; rehashOnLogin upgrades them to argon2id the
// first time the user signs in.
const result = await importUsers(records, {
credentialStore: createNeonCredentialStore(process.env.DATABASE_URL),
onCreateUser: ({ email, fields }) => createUser({ email, ...fields }),
// Optional: detect + verify legacy formats (no rehash needed at import time).
passwordVerifier: async (password, hash) => {
if (hash.startsWith('auth0_pbkdf2:')) return verifyAuth0Pbkdf2(password, hash);
if (hash.startsWith('cognito_sha256:')) return verifyCognitoSha256(password, hash);
return Bun.password.verify(password, hash);
}
});
console.info(`imported ${result.imported}, skipped ${result.skipped}`);
// On the credentials block, opt in to silent upgrade-on-login:
credentials: {
// ...the rest of your credentials config
passwordVerifier: async (password, hash) => {
if (isLegacyHash(hash)) {
return hash.startsWith('auth0_pbkdf2:')
? verifyAuth0Pbkdf2(password, hash)
: verifyCognitoSha256(password, hash);
}
return Bun.password.verify(password, hash);
},
rehashOnLogin: true // calls rehashCredentialPassword behind the scenes
}