AbsoluteJS

Multi-Factor Auth

Native second-factor authentication for credential accounts: TOTP via any authenticator app (Google Authenticator, Authy, 1Password), single-use backup codes, provider-managed phone verification, and per-factor lockout — no second auth library required. Twilio Verify can own OTP generation, delivery, fraud evaluation, and code checking while Absolute Auth retains enrollment and session policy.

#Server Setup

Add an mfa block alongside credentials. auth() auto-wires the gate: once a user has a verified factor, login parks the session and returns mfa_required instead of authenticating, and only the challenge route completes it. The TOTP secret is AES-GCM encrypted at rest when you set encryptionKey. Failed second-factor attempts are tracked independently of the password lockout and lock out after totpMaxAttempts.

TS
import { auth, createNeonMfaStore } from '@absolutejs/auth';
import { createTwilioVerificationProvider } from '@absolutejs/auth-twilio';
import { Twilio } from 'twilio';

const twilio = new Twilio(
  process.env.TWILIO_ACCOUNT_SID!,
  process.env.TWILIO_AUTH_TOKEN!,
);

await auth<User>({
  providersConfiguration: {},
  credentials: {
    credentialStore: createNeonCredentialStore(process.env.DATABASE_URL),
    getUserByEmail: (email) => findUserByEmail(email)
    // ...password policy, email hooks, etc.
  },
  mfa: {
    mfaStore: createNeonMfaStore(process.env.DATABASE_URL),
    getUserId: (user) => user.sub,
    // Resolve the parked identity back into a user during a challenge.
    // For credentials this is just a lookup by the parked email:
    getChallengeUser: (identity) => findUserByEmail(String(identity.email)),
    // AES-GCM key (base64url) that encrypts the TOTP secret at rest. Generate once with
    // generateEncryptionKey() and keep it in your secret manager — set it in any real deploy.
    encryptionKey: process.env.MFA_ENCRYPTION_KEY,
    issuer: 'Acme',              // shown in the authenticator app
    totpMaxAttempts: 5           // 2nd-factor lockout, independent of password lockout
  },
  verificationProvider: createTwilioVerificationProvider({
    profile: {
      client: twilio,
      verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID!,
    },
    // Match the token lifetime configured on the Verify Service.
    serviceTokenTtlMs: 10 * 60 * 1000,
  }),
});

// With credentials + mfa configured, auth() auto-wires the MFA gate: once a user has a
// verified factor, login no longer mints a session directly — it parks the login and
// returns { status: 'mfa_required' }, and only /auth/mfa/challenge completes it.

#Twilio Verify provider

@absolutejs/auth-twilio implements Auth's verificationProvider contract. The adapter surface supports SMS, WhatsApp, and voice-call Verify channels, purpose-specific templates, locale/rate-limit inputs, and tenant-isolated account routing. The built-in MFA routes shown here invoke its SMS channel; custom Auth verification flows can use the other channels and routing inputs. Unknown provider statuses fail closed.

TS
import { createTwilioVerificationProvider } from '@absolutejs/auth-twilio';

const verificationProvider = createTwilioVerificationProvider({
  profile: { client: twilio, verifyServiceSid },
  serviceTokenTtlMs: 600_000,
  templates: {
    mfa_enrollment: { sms: 'HJ_ENROLLMENT_TEMPLATE' },
    mfa_challenge: { sms: 'HJ_SMS_CHALLENGE_TEMPLATE' },
  },
  // Optional non-PII provider tags. Never return phone, email, or user names.
  buildTags: ({ purpose }) => ({ purpose }),
});

await auth({
  credentials,
  mfa,
  providersConfiguration: {},
  verificationProvider,
});
Keep Auth Verify separate from Dispatch
Twilio Verify creates and checks secrets. Use @absolutejs/dispatch-twilio only for application-authored alerts, transactional messages, and carrier/rich messaging workflows. The configured serviceTokenTtlMs must match the Verify Service because Twilio's start response does not expose that lifetime.

Auth still enforces enrollment state, resend cooldown, failed-attempt lockout, audit events, and session promotion. Prefer Twilio API keys when constructing the production SDK client, and never place direct personal data in provider tags.

#Routes

Enrollment routes require an authenticated caller; the challenge route runs against the parked login.

MethodRouteDescription
POST/auth/mfa/totp/setupBegin TOTP enrollment (caller must be authenticated).
Body: {} — returns { secret, uri }; uri is the otpauth:// value for the QR
POST/auth/mfa/totp/verifyActivate TOTP — returns the backup codes ONCE.
Body: { code } — returns { backupCodes }
POST/auth/mfa/sms/setupText a verification code to the phone.
Body: { phone } — returns { status: 'sent' }; mounted when a verificationProvider is configured
POST/auth/mfa/sms/verifyActivate the SMS factor.
Body: { code } — returns { status: 'verified' }
POST/auth/mfa/challengeComplete a login that returned { status: 'mfa_required' }.
Accepts three request shapes — see the table below

The challenge route accepts three request shapes:

Request bodyResponseFactor
{ code }{ status: 'authenticated' }TOTP or backup code
{ factor: 'sms', action: 'send' }{ status: 'sent' }SMS — sends the code
{ factor: 'sms', code }{ status: 'authenticated' }SMS
Returned exactly once
The secret and backup codes are never re-fetchable. Store the QR at enrollment and the backup codes somewhere the user can save them.

#Enrolling TOTP (React)

Call setup from an authenticated page, render the otpauth:// URI as a QR code, then confirm the first code before the factor activates — enrollment stays inactive until a code is verified, so there is never a silently-active unconfirmed secret. verify returns the single-use backup codes exactly once.

TS
// 1. ENROLL — call setup from an authenticated page, render the otpauth URI as a QR.
import QRCode from 'qrcode';

async function startTotpEnrollment() {
  const res = await fetch('/auth/mfa/totp/setup', {
    method: 'POST',
    credentials: 'include' // send the session cookie
  });
  const { secret, uri } = await res.json();
  const qrDataUrl = await QRCode.toDataURL(uri); // render <img src={qrDataUrl} />
  return { secret, qrDataUrl };                  // show 'secret' as a manual-entry fallback
}

// 2. CONFIRM — the user scans, types the 6-digit code; verify activates the factor.
//    Enrollment is NOT active until this succeeds — no silently-active unconfirmed secret.
async function confirmTotpEnrollment(code: string) {
  const res = await fetch('/auth/mfa/totp/verify', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ code })
  });
  if (!res.ok) throw new Error('Invalid code');
  const { backupCodes } = await res.json();
  return backupCodes; // show these ONCEtell the user to save them. Never retrievable again.
}

#Login Challenge (React)

For an enrolled user, a normal login returns mfa_required. The pending login is held in an httpOnly cookie — send the 6-digit code (or a backup code) to the challenge route with credentials: 'include' to complete it. A 401 with "Too many attempts" means the lockout tripped.

TS
// 3. LOGIN — a normal credential login now returns mfa_required for enrolled users.
async function login(email: string, password: string) {
  const res = await fetch('/auth/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ email, password })
  });
  const data = await res.json();
  if (data.status === 'mfa_required') {
    return 'needs-second-factor'; // route to your <MfaChallenge /> screen
  }
  return 'authenticated';         // no MFA enrolled -> straight in
}

// 4. CHALLENGE — submit the authenticator (or backup) code to complete login.
//    The pending login is held in an httpOnly cookie; just keep sending credentials.
async function submitChallenge(code: string) {
  const res = await fetch('/auth/mfa/challenge', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ code }) // a backup code works here too
  });
  if (res.status === 401) {
    const msg = await res.text();
    // 'Too many attempts' -> locked out (totpMaxAttempts hit); 'Invalid MFA code' -> retry
    throw new Error(msg);
  }
  return true; // session cookie is now setthe user is fully authenticated
}

#Challenge Component

A drop-in challenge screen. autoComplete="one-time-code" lets mobile keyboards surface the code, and a backup code is accepted in the same field.

TSX
import { useState } from 'react';

export function MfaChallenge({ onSuccess }: { onSuccess: () => void }) {
  const [code, setCode] = useState('');
  const [error, setError] = useState<string | null>(null);

  const submit = async (event: React.FormEvent) => {
    event.preventDefault();
    setError(null);
    const res = await fetch('/auth/mfa/challenge', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ code })
    });
    if (res.ok) return onSuccess();
    setError(await res.text()); // 'Invalid MFA code' | 'Too many attempts'
  };

  return (
    <form onSubmit={submit}>
      <label>
        Authenticator code
        <input
          value={code}
          onChange={(event) => setCode(event.target.value)}
          inputMode="numeric"
          autoComplete="one-time-code"
          maxLength={6}
          placeholder="123456"
        />
      </label>
      {error && <p role="alert">{error}</p>}
      <button type="submit">Verify</button>
      <p>Lost your device? Enter one of your backup codes instead.</p>
    </form>
  );
}