AbsoluteJS

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.

TS
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:

MethodRouteDescription
POST/auth/registerCreate a local account.
Body: { email, password, ...extraFields }
POST/auth/loginSign in with email and password.
Body: { email, password } — returns { status }
POST/auth/verify-emailConfirm an email with its verification token.
Body: { token }
POST/auth/verify-email/requestRequest a new verification email.
Body: { email }
POST/auth/reset-passwordSet a new password with a reset token.
Body: { token, password }
POST/auth/reset-password/requestRequest a password-reset email.
Body: { email }
Passwords are hashed with Bun.password (argon2id). Existing argon2id/bcrypt hashes verify as-is, so you can migrate a legacy user table with no rehash.

#Multi-Factor Auth

1
Enroll a factor
TOTP (authenticator apps) plus single-use backup codes. The TOTP secret is AES-GCM encrypted at rest.
2
Login parks the session
When MFA is enrolled, login parks the session instead of authenticating.
3
Verify to promote
The parked session is only promoted once a factor is verified.
TS
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.

TS
// 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.

TS
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

1
Import the export
importUser / importUsers migrate an Auth0, Cognito, or Firebase export in one pass.
2
Verify every hash format
argon2id and bcrypt hashes verify natively via Bun.password. Legacy formats (Auth0 PBKDF2, Cognito SHA-256) are recognized by isLegacyHash and verified by the matching verifyAuth0Pbkdf2 / verifyCognitoSha256.
3
Upgrade on first login
Opt in to rehashOnLogin and the first successful sign-in silently upgrades the stored hash to argon2id. No forced password reset, no public breach.
TS
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
}