BaasixBaasix
GuidesAuthentication

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

  1. Overview
  2. Environment Configuration
  3. Enrollment Flow
  4. Login Challenge Flow
  5. Disabling Two-Factor
  6. Design Decisions
  7. Error Codes
  8. 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,TWOFACTOR

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

StatusCodeCause
400TWO_FACTOR_REQUIRES_PASSWORDThe 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" }
FieldTypeRequiredDescription
codestringYesThe 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:

StatusCodeCause
401INVALID_TWO_FACTOR_CODEThe 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"
}
FieldTypeRequiredDescription
twoFactorTokenstringYesThe token from the TWO_FACTOR_REQUIRED login response
codestringYesA 6-digit TOTP code or one of the 10 backup codes
authModestringNojwt (default) or cookie

On success, returns the same full login response as a normal POST /auth/login (token, user, role, permissions, tenant).

Error:

StatusCodeCause
401INVALID_TWO_FACTOR_CODECode 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" }
FieldTypeRequiredDescription
passwordstringYesThe 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/enable returns TWO_FACTOR_REQUIRES_PASSWORD (400) for accounts that only have social or passkey credentials — there's no password to gate disable against, 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

CodeEndpoint(s)StatusMeaning
TWO_FACTOR_REQUIREDPOST /auth/login200Password was correct; a 2FA code is now required
INVALID_TWO_FACTOR_CODEPOST /auth/2fa/verify-setup, POST /auth/2fa/verify401Code (or backup code) didn't match, or challenge expired/reused
TWO_FACTOR_REQUIRES_PASSWORDPOST /auth/2fa/enable400Account 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.


On this page