Skip to main content

MFA Factor Types

VoxKey supports five MFA factor types for step-up verification through a unified challenge/verify API. Each factor has the same interface -- the difference is in how the user provides verification and in the security properties.

Comparison

FactorAPI valueDeliveryUser actionPhishing resistantOffline capable
TOTPtotpNone (app-generated)Enter 6-digit code from authenticator appNoYes
EmailemailCode sent to user's emailEnter 6-digit code from emailNoNo
SMSsmsCode sent via SMSEnter 6-digit code from SMSNoNo
WebAuthnwebauthnBrowser promptTouch sensor / biometric / security keyYesYes
Backup codebackup_codeNone (pre-generated)Enter 8-character codeNoYes

Rate limits, TTL, and max attempts

FactorChallenge TTLMax attemptsChallenge rate limit
TOTP5 minutes55 per minute per user
Email10 minutes53 per minute per user
SMS10 minutes53 per minute per user
WebAuthn5 minutes35 per minute per user
Backup code10 minutes55 per minute per user

After exceeding max attempts, the challenge is locked and returns 410 Gone. The user must create a new challenge. After exceeding the challenge rate limit, new challenge creation returns 429 Too Many Requests.

Global verify rate limit: 10 verify attempts per 5 minutes per user+IP.


TOTP

Uses authenticator apps (Google Authenticator, Authy, 1Password, etc.) to generate time-based one-time passwords.

  • No outbound delivery -- code is generated on the user's device
  • Codes valid for ~30 seconds with a +/-1 window tolerance for clock drift
  • Most reliable: works offline, no dependency on email/SMS providers
  • Lowest operational cost (no per-verification delivery charges)

Challenge response:

{
"challenge_id": "550e8400-...",
"factor_type": "totp",
"challenge_type": "code",
"expires_at": "2026-04-03T10:05:00Z"
}

Verify request:

{ "code": "482916" }

Best for: General-purpose step-up verification. Recommended as the default factor for most applications.


Email

A one-time 6-digit code sent to the user's verified email address.

  • Challenge triggers delivery via the realm's notification service
  • Code hash stored in the challenge payload (stateless -- no database table)
  • Creating a new email challenge invalidates the previous one (resend semantics)
  • Response includes masked email destination

Challenge response:

{
"challenge_id": "550e8400-...",
"factor_type": "email",
"challenge_type": "code",
"expires_at": "2026-04-03T10:10:00Z",
"delivery": {
"destination": "t***@example.com",
"sent": true
}
}

Verify request:

{ "code": "739201" }

Best for: Users who haven't set up an authenticator app. Zero setup required since every user with a verified email can use this factor.

Trade-offs: Depends on email delivery speed. Subject to email compromise. Slower UX (user must switch to their email client).


SMS

A one-time 6-digit code sent via text message to the user's verified phone number.

  • Same challenge/verify pattern as email, but via SMS channel
  • Requires the user to have a phone number enrolled
  • Creating a new SMS challenge invalidates the previous one
  • Response includes masked phone number
  • Lower challenge rate limit (3/min) due to delivery cost

Challenge response:

{
"challenge_id": "550e8400-...",
"factor_type": "sms",
"challenge_type": "code",
"expires_at": "2026-04-03T10:10:00Z",
"delivery": {
"destination": "+1***4567",
"sent": true
}
}

Verify request:

{ "code": "518473" }

Best for: Fallback option, familiar pattern for consumer apps.

caution

SMS is considered a "restricted" authenticator by NIST SP 800-63B due to SIM-swap attacks. For high-security scenarios (financial transactions, admin operations), prefer TOTP or WebAuthn.


WebAuthn / Passkey

Hardware security keys (YubiKey), platform authenticators (Touch ID, Face ID, Windows Hello), and passkeys.

  • Challenge generates PublicKeyCredentialRequestOptions for the browser's WebAuthn API
  • userVerification is set to "required" for step-up challenges
  • Phishing resistant -- cryptographically bound to the origin
  • Only 3 max attempts (authentication failures are rare with WebAuthn)

Challenge response:

{
"challenge_id": "550e8400-...",
"factor_type": "webauthn",
"challenge_type": "webauthn",
"expires_at": "2026-04-03T10:05:00Z",
"webauthn_options": {
"challenge": "base64url-encoded-challenge",
"rpId": "app.voxkey.io",
"allowCredentials": [
{
"type": "public-key",
"id": "base64url-credential-id"
}
],
"userVerification": "required",
"timeout": 300000
}
}

Client-side integration:

// 1. Create the challenge
const { challenge_id, webauthn_options } = await createChallenge('webauthn');

// 2. Call the browser WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
challenge: base64urlDecode(webauthn_options.challenge),
rpId: webauthn_options.rpId,
allowCredentials: webauthn_options.allowCredentials.map(c => ({
type: c.type,
id: base64urlDecode(c.id),
})),
userVerification: webauthn_options.userVerification,
timeout: webauthn_options.timeout,
},
});

// 3. Send the assertion to the verify endpoint
await fetch(`/api/v1/{realm}/mfa/challenges/${challenge_id}/verify`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: {
id: credential.id,
rawId: base64urlEncode(credential.rawId),
response: {
authenticatorData: base64urlEncode(credential.response.authenticatorData),
clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
signature: base64urlEncode(credential.response.signature),
},
type: credential.type,
},
}),
});

Best for: Highest security requirements. Financial services, enterprise, compliance-heavy environments.

Trade-offs: Requires browser support (available in all modern browsers). Platform authenticators are device-bound. Cannot be used in non-browser contexts.


Backup codes

Pre-generated one-time codes for emergency access when primary factors are unavailable.

  • 10 alphanumeric codes generated at enrollment (8 characters each)
  • Each code can only be used once -- consumed immediately after successful verification
  • No outbound delivery -- user must have saved the codes at enrollment time
  • Intended as a recovery mechanism, not a primary factor

Challenge response:

{
"challenge_id": "550e8400-...",
"factor_type": "backup_code",
"challenge_type": "code",
"expires_at": "2026-04-03T10:10:00Z"
}

Verify request:

{ "code": "A3BGP2ZW" }
warning

Backup codes are single-use. After verification, the code is permanently consumed. Prompt users to regenerate codes when their supply is low. After using a backup code for step-up, encourage re-enrollment of a primary factor (TOTP or WebAuthn).


Choosing factors for your application

ScenarioRecommended factors
General web appTOTP (primary) + backup codes (recovery)
Financial servicesWebAuthn (primary) + TOTP (fallback) + backup codes
Enterprise / complianceWebAuthn required, TOTP as recovery
Consumer app with phone verificationEmail + SMS (familiar UX, lower security bar)
Developer tools / API platformsTOTP (developers are familiar with authenticator apps)