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.
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.
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,
});@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.
| Method | Route | Description |
|---|---|---|
| POST | /auth/mfa/totp/setup | Begin TOTP enrollment (caller must be authenticated). Body: {} — returns { secret, uri }; uri is the otpauth:// value for the QR |
| POST | /auth/mfa/totp/verify | Activate TOTP — returns the backup codes ONCE. Body: { code } — returns { backupCodes } |
| POST | /auth/mfa/sms/setup | Text a verification code to the phone. Body: { phone } — returns { status: 'sent' }; mounted when a verificationProvider is configured |
| POST | /auth/mfa/sms/verify | Activate the SMS factor. Body: { code } — returns { status: 'verified' } |
| POST | /auth/mfa/challenge | Complete a login that returned { status: 'mfa_required' }. Accepts three request shapes — see the table below |
The challenge route accepts three request shapes:
| Request body | Response | Factor |
|---|---|---|
{ code } | { status: 'authenticated' } | TOTP or backup code |
{ factor: 'sms', action: 'send' } | { status: 'sent' } | SMS — sends the code |
{ factor: 'sms', code } | { status: 'authenticated' } | SMS |
#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.
// 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 ONCE — tell 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.
// 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 set — the 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.
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>
);
}