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
| Factor | API value | Delivery | User action | Phishing resistant | Offline capable |
|---|---|---|---|---|---|
| TOTP | totp | None (app-generated) | Enter 6-digit code from authenticator app | No | Yes |
email | Code sent to user's email | Enter 6-digit code from email | No | No | |
| SMS | sms | Code sent via SMS | Enter 6-digit code from SMS | No | No |
| WebAuthn | webauthn | Browser prompt | Touch sensor / biometric / security key | Yes | Yes |
| Backup code | backup_code | None (pre-generated) | Enter 8-character code | No | Yes |
Rate limits, TTL, and max attempts
| Factor | Challenge TTL | Max attempts | Challenge rate limit |
|---|---|---|---|
| TOTP | 5 minutes | 5 | 5 per minute per user |
| 10 minutes | 5 | 3 per minute per user | |
| SMS | 10 minutes | 5 | 3 per minute per user |
| WebAuthn | 5 minutes | 3 | 5 per minute per user |
| Backup code | 10 minutes | 5 | 5 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.
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
PublicKeyCredentialRequestOptionsfor the browser's WebAuthn API userVerificationis 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" }
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
| Scenario | Recommended factors |
|---|---|
| General web app | TOTP (primary) + backup codes (recovery) |
| Financial services | WebAuthn (primary) + TOTP (fallback) + backup codes |
| Enterprise / compliance | WebAuthn required, TOTP as recovery |
| Consumer app with phone verification | Email + SMS (familiar UX, lower security bar) |
| Developer tools / API platforms | TOTP (developers are familiar with authenticator apps) |