Two-Factor Authentication
Baasix supports TOTP (Time-based One-Time Password) two-factor authentication, compatible with standard authenticator apps (Google Authenticator, Authy, 1Password, etc.), plus single-use backup codes for account recovery.
Table of Contents
- Overview
- Environment Configuration
- Enrollment Flow
- Login Challenge Flow
- Disabling Two-Factor
- Design Decisions
- Error Codes
- Client Integration (SDK)
Overview
TOTP parameters: SHA1 algorithm, 6-digit codes, 30-second time step, with a ±1 step verification window to tolerate clock drift. Each account also gets 10 single-use backup codes, generated once at enrollment, for use when the authenticator app is unavailable.
Two-factor authentication is enabled per-account (there's no org-wide "force 2FA" setting) and only applies to password login — see Design Decisions for why social, magic-link, and passkey logins bypass it.
Environment Configuration
Two-factor is gated behind AUTH_SERVICES_ENABLED:
AUTH_SERVICES_ENABLED=LOCAL,TWOFACTORTWOFACTOR requires LOCAL to be meaningful, since 2FA enrollment requires a password credential (see below). No other environment variables are needed — TOTP parameters are fixed and not currently configurable.
Enrollment Flow
Enrollment is a two-step process: enable (generates a secret + QR data + backup codes), then verify-setup (confirms the user actually scanned the QR code and can produce a valid code).
1. Enable
Endpoint: POST /auth/2fa/enable
Authentication: Required (Bearer token)
Response:
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauthUrl": "otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp",
"backupCodes": [
"1a2b3c4d",
"5e6f7g8h",
"9i0j1k2l",
"3m4n5o6p",
"7q8r9s0t",
"u1v2w3x4",
"y5z6a7b8",
"c9d0e1f2",
"g3h4i5j6",
"k7l8m9n0"
]
}Render otpauthUrl as a QR code (e.g. with a client-side QR library) so the user can scan it into an authenticator app, and show backupCodes once — instruct the user to store them somewhere safe, since they can't be retrieved again after this response.
Errors:
| Status | Code | Cause |
|---|---|---|
| 400 | TWO_FACTOR_REQUIRES_PASSWORD | The account has no password credential (e.g. social-only or passkey-only account) |
| 400 | (already enabled) | 2FA is already enabled for this account |
2. Verify Setup
Endpoint: POST /auth/2fa/verify-setup
Authentication: Required (Bearer token)
Request Body:
{ "code": "123456" }| Field | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | The 6-digit TOTP code from the authenticator app |
Response:
{ "enabled": true }2FA is not actually active on the account until this step succeeds — enable alone only generates and stores the pending secret.
Error:
| Status | Code | Cause |
|---|---|---|
| 401 | INVALID_TWO_FACTOR_CODE | The submitted code doesn't match |
Login Challenge Flow
Once 2FA is enabled, POST /auth/login no longer completes a session directly for that account — it returns a challenge that must be resolved with a second call.
1. Login Returns a Challenge
Endpoint: POST /auth/login
Request Body: (unchanged — same as regular email/password login)
{ "email": "user@example.com", "password": "password123" }Response when 2FA is enabled (200 OK, no session issued yet):
{
"twoFactorRequired": true,
"twoFactorToken": "short-lived-challenge-token",
"code": "TWO_FACTOR_REQUIRED"
}The challenge is single-use and expires after 5 minutes. If the user doesn't complete step 2 in time, they must log in again from the beginning.
2. Verify the Code
Endpoint: POST /auth/2fa/verify
Authentication: Not required (this endpoint completes the login). Rate-limited like other public auth endpoints.
Request Body:
{
"twoFactorToken": "short-lived-challenge-token",
"code": "123456",
"authMode": "jwt"
}| Field | Type | Required | Description |
|---|---|---|---|
| twoFactorToken | string | Yes | The token from the TWO_FACTOR_REQUIRED login response |
| code | string | Yes | A 6-digit TOTP code or one of the 10 backup codes |
| authMode | string | No | jwt (default) or cookie |
On success, returns the same full login response as a normal POST /auth/login (token, user, role, permissions, tenant).
Error:
| Status | Code | Cause |
|---|---|---|
| 401 | INVALID_TWO_FACTOR_CODE | Code doesn't match and isn't an unused backup code, or the twoFactorToken is expired/already used |
Backup codes are single-use
Each backup code works exactly once. Once a backup code is used to complete /auth/2fa/verify, it's marked consumed
and rejected on any future attempt.
Disabling Two-Factor
Endpoint: POST /auth/2fa/disable
Authentication: Required (Bearer token)
Request Body:
{ "password": "currentPassword123" }| Field | Type | Required | Description |
|---|---|---|---|
| password | string | Yes | The account's current password, for confirmation |
Response:
{ "disabled": true }Requiring the password here (rather than just a valid session) prevents a hijacked-but-unlocked session, or an XSS-stolen token, from silently turning off the user's second factor.
Design Decisions
These are intentional trade-offs worth understanding before you build a UI around 2FA:
- 2FA gates password login only. Social OAuth, magic-link, and passkey logins all bypass the 2FA challenge entirely. This matches better-auth semantics: those are already possession-based or federated-trust factors, so stacking TOTP on top adds friction without meaningfully more security. If a user wants their account fully protected, encourage passkeys as the primary factor rather than password + TOTP.
- Enabling 2FA requires a password credential.
POST /auth/2fa/enablereturnsTWO_FACTOR_REQUIRES_PASSWORD(400) for accounts that only have social or passkey credentials — there's no password to gatedisableagainst, so 2FA enrollment is blocked until the user sets one. - Backup codes, not SMS. Baasix doesn't send 2FA codes via SMS/email; recovery is exclusively via the 10 backup codes issued at enrollment. Losing both the authenticator and all backup codes means the account is only recoverable through your own admin/support process (e.g. an admin manually disabling 2FA on the user's record).
- All public 2FA endpoints are rate-limited the same way other auth endpoints are (
AUTH_RATE_LIMIT/AUTH_RATE_LIMIT_INTERVAL), which also mitigates brute-forcing 6-digit codes.
Error Codes
| Code | Endpoint(s) | Status | Meaning |
|---|---|---|---|
TWO_FACTOR_REQUIRED | POST /auth/login | 200 | Password was correct; a 2FA code is now required |
INVALID_TWO_FACTOR_CODE | POST /auth/2fa/verify-setup, POST /auth/2fa/verify | 401 | Code (or backup code) didn't match, or challenge expired/reused |
TWO_FACTOR_REQUIRES_PASSWORD | POST /auth/2fa/enable | 400 | Account has no password credential to gate 2FA against |
Client Integration (SDK)
// --- Enrollment (while logged in) ---
const setup = await baasix.auth.twoFactor.enable();
// { secret, otpauthUrl, backupCodes }
// Render setup.otpauthUrl as a QR code; show setup.backupCodes once for the user to save.
await baasix.auth.twoFactor.verifySetup('123456');
// 2FA is now active on the account.
// --- Login (2FA-aware) ---
const result = await baasix.auth.login({ email, password });
if ('twoFactorRequired' in result) {
// Prompt for a TOTP or backup code, then:
const session = await baasix.auth.twoFactor.verify(result.twoFactorToken, '123456');
console.log('Logged in as:', session.user.email);
} else {
console.log('Logged in as:', result.user.email);
}
// --- Disable (requires password) ---
await baasix.auth.twoFactor.disable('currentPassword123');Discovering Whether 2FA Is Enabled Server-Wide
const methods = await baasix.auth.getAuthMethods();
if (methods.twoFactor) {
// Show "Enable two-factor authentication" in account settings
}Note this only tells you whether the TWOFACTOR service is enabled server-wide — whether a specific user has it turned on is a property of their account, not exposed via discovery.
Related Documentation
- Authentication API - Discovery, login, and core auth endpoints
- Passkeys - WebAuthn passwordless sign-in
- SSO & Social Authentication - OAuth providers
- Session Limits Feature - Concurrent session control
- JavaScript SDK -
auth.twoFactor.*reference