Qeet Docs

MFA & step-up

TOTP + recovery codes, email/SMS OTP factors, WebAuthn as a second factor, step-up MFA, and an adaptive risk engine (bot-score, impossible travel, device reputation).

Qeet ID ships a complete multi-factor system: TOTP with recovery codes, email/SMS OTP factors, WebAuthn as a second factor, and step-up MFA that gates sensitive actions behind a recent verification.

All implemented

TOTP, OTP factors, WebAuthn-2FA, and step-up are live. Any factor verification refreshes a recent-MFA window that a server-side RequireRecentMFA middleware checks before sensitive operations.

Factors

FactorEndpointsNotes
TOTP (RFC 6238)/v1/mfa/totp/*6-digit authenticator codes; enroll → confirm → verify.
Recovery codes/v1/mfa/recovery-codes*bcrypt-hashed single-use codes; regenerable.
Email / SMS OTP/v1/mfa/otp/factors*Per-factor challenge + confirm via real senders.
WebAuthn 2FA/v1/mfa/webauthn/*Reuses the user's registered passkeys as a second factor.

TOTP

Three-step enrollment, then verification at sign-in.

Start enrollment

Returns the shared secret + provisioning URI to render as a QR code.

POST/v1/mfa/totp/enroll/startBegin TOTP enrollment

Confirm

The user enters the first 6-digit code to prove the authenticator is synced.

POST/v1/mfa/totp/enroll/confirmConfirm enrollment

Verify at sign-in

POST/v1/mfa/totp/verifyVerify a TOTP code

Remove TOTP with DELETE /v1/mfa/totp (this is a sensitive action — see Step-up).

Recovery codes

Issued at enrollment, single-use, bcrypt-hashed. Users can regenerate the set (another step-up-gated action).

GET/v1/mfa/recovery-codesList remaining recovery codes
POST/v1/mfa/recovery-codes/regenerateRegenerate the set

Email / SMS OTP factors

Register a contact factor, then challenge + confirm it as a second step.

POST/v1/mfa/otp/factorsRegister an OTP factor (email/SMS)
POST/v1/mfa/otp/factors/{id}/challengeSend a code
POST/v1/mfa/otp/factors/{id}/confirmConfirm enrollment
POST/v1/mfa/otp/verifyVerify an OTP at sign-in

SMS is lower assurance

Prefer TOTP or WebAuthn as the primary second factor; SMS is vulnerable to SIM-swap. SMS/email delivery also depends on a configured sending domain / Twilio account.

WebAuthn as a second factor

Step-up with a registered passkey. Unlike passwordless login, this asserts the known user and issues no new token — it only proves presence to refresh the MFA window.

POST/v1/mfa/webauthn/challengeGet assertion options
POST/v1/mfa/webauthn/verifyVerify the assertion

Step-up MFA

Any successful factor verification refreshes a recent-verification window. The RequireRecentMFA middleware gates sensitive endpoints (e.g. disabling MFA, regenerating recovery codes) and returns an MFA challenge if the window has lapsed.

Check whether the user currently satisfies the window:

GET/v1/mfa/step-up/statusIs a recent MFA present?
Bash
curl https://api.id.qeet.in/v1/mfa/step-up/status \
  -H "Authorization: Bearer $ACCESS_TOKEN"
# → { "recent": false }  →  prompt the user to re-verify a factor

The pattern: call step-up/status before a sensitive action; if not recent, run any factor's verify endpoint to refresh the window, then retry. The server enforces this independently — clients can't bypass it.

Adaptive MFA (risk-based)

On the hosted-login (cookie) flow, a "remembered device" can normally skip the second factor. Qeet ID scores each such login and overrides that skip — forcing MFA anyway — when the score crosses a per-tenant threshold. The base signal is bot-likeness (a User-Agent heuristic); two more are additive, independently toggled, and off by default:

  • Impossible travel — a login from a different country than the user's last one, sooner than a configurable minimum plausible travel time later.
  • Device reputation — a login from a browser + OS combination never seen before for that user.
GET/v1/tenants/{tenantID}/security/risk-settingsGet risk settings
PUT/v1/tenants/{tenantID}/security/risk-settingsUpdate risk settings
risk settings
JSON
{
  "medium_threshold": 0.5,
  "high_threshold": 0.8,
  "force_mfa_at_level": "high",
  "impossible_travel_enabled": false,
  "min_travel_hours": 3,
  "device_reputation_enabled": false
}

Impossible travel needs a geo signal

Qeet ID ships no server-side GeoIP lookup. The country comes from a configurable trusted-upstream-proxy header (e.g. Cloudflare's CF-IPCountry, set via GEO_COUNTRY_HEADER). With none configured, enabling this signal is a safe no-op — it never fires (fail-open), rather than guessing from a raw IP. This is the same "interface ships, live dependency is external ops" pattern as KMS BYOK.

Also in the console: Security & Compliance → Threat Protection → Risk Settings.

On this page