Driftstack DRIFTSTACK docs
Docs

Two-factor authentication (MFA)

Driftstack supports time-based one-time passwords (TOTP) per RFC 6238 plus single-use recovery codes. Once enrolled, sign-in to the dashboard requires a 6-digit code from your authenticator app, and the most sensitive operations such as disabling MFA require a fresh code within a 15-minute window.

This doc covers the API surface. The dashboard at /settings → Two-factor authentication wraps these endpoints into a flow most customers will never need to call directly.

MFA credential changes are interactive-account operations. POST /enroll, POST /verify, both disable routes, and recovery-code regeneration require a web-session bearer; API keys cannot call them, even with account_owner. GET /v1/account/mfa remains available to appropriately scoped API keys for status and monitoring only.

Enrollment

Enrollment is a two-step flow: start enrollment to mint a fresh TOTP secret, then verify a code from the authenticator app to activate it and receive recovery codes.

Start enrollment

POST /v1/account/mfa/enroll

Requires an interactive web-session bearer. No body. Generates a fresh TOTP secret on the server, encrypts it at rest, and returns the otpauth URI for QR rendering plus the manual- entry base32 secret.

Response (200):

{
  "otpauth_uri": "otpauth://totp/Driftstack:[email protected]?secret=...&issuer=Driftstack&algorithm=SHA1&digits=6&period=30",
  "secret_base32": "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
  "algorithm": "SHA1",
  "digits": 6,
  "period_seconds": 30
}

If MFA is already enrolled, the endpoint returns 409 Conflict. Disable first via DELETE /v1/account/mfa, then re-enroll.

Re-calling /enroll while still pending (no /verify yet) is OK — each call returns a fresh secret. The customer’s authenticator app should be re-scanned each time.

Confirm + receive recovery codes

POST /v1/account/mfa/verify

Requires the same interactive web-session boundary as start enrollment; an API key cannot activate a pending factor.

{
  "code": "123456"
}

The server checks the 6-digit against the pending secret with ±1 window drift tolerance (90 seconds total). On success, marks the enrollment active and returns 10 single-use recovery codes:

Response (200):

{
  "recovery_codes": [
    "ABCDE-FGHJK",
    "MNPQR-STVWX",
    ...
  ]
}

Recovery codes are shown ONCE. Store them somewhere safe (a password manager, a printed copy, a secure note). Each code works exactly once. Without your authenticator AND these codes, account access requires support intervention.

If the code is invalid, the endpoint returns 400 Bad Request. The pending secret stays alive — the customer can retype within the authenticator’s window. If the code is malformed (not 6 digits) the response is also 400.

Status

GET /v1/account/mfa

Requires the broad read scope; account_owner also satisfies the gate. Resource-granular and zero-scope keys cannot inspect enrollment, last-use, or remaining recovery-code metadata.

Response (200):

{
  "enrolled": true,
  "enrolled_at": "2026-05-08T22:14:11.000Z",
  "last_used_at": "2026-05-08T22:30:55.000Z",
  "unused_recovery_codes": 9
}

When not enrolled: enrolled: false, both timestamps null, unused_recovery_codes: 0.

Login challenge

When MFA is enrolled, POST /v1/auth/login responds with a challenge token instead of a session:

Request:

{ "email": "[email protected]", "password": "..." }

Response (200):

{
  "mfa_required": true,
  "challenge_token": "...",
  "challenge_expires_at": "2026-05-08T22:35:00.000Z"
}

The token is valid for 5 minutes, single-use, and bound to the issuing IP address. Exchange it for the real session at:

POST /v1/auth/mfa/challenge

{
  "challenge_token": "...",
  "code": "123456"
}

Or use a recovery code (hyphen optional; codes normalize to upper- case + no separators internally):

{
  "challenge_token": "...",
  "recovery_code": "ABCDE-FGHJK"
}

Response (200):

{
  "session": {
    "token": "ds_web_session_token_...",
    "expires_at": "2026-06-07T22:30:00.000Z",
    "account_id": "acc_..."
  },
  "via": "totp"
}

via is "totp" when matched against the 6-digit; "recovery" when a recovery code was consumed. Recovery-code success consumes the row permanently — it can’t be used again.

Failure modes:

  • Invalid 6-digit / recovery code: 400 Bad Request. Token is NOT consumed; customer can retype.
  • Token expired or unknown: 400 Bad Request.
  • Token + IP mismatch: 400 Bad Request. Defense against challenge- token theft from chat / email paste; legitimate caller is on the same IP.
  • Token already consumed (re-use after success): 400 Bad Request.

Magic-link consume, password-reset confirm, and linked-IdP/OAuth sign-in use the same MFA gate as password login. For an enrolled account, each returns the mfa_required challenge shape above and mints no web session until /v1/auth/mfa/challenge succeeds with TOTP or a recovery code. A mailbox or IdP assertion is the first factor; it never replaces the enrolled second factor.

Password reset changes the password and invalidates predecessor web sessions before returning that challenge, so the successful MFA exchange is the only successor session.

Email verification challenges an enrolled factor too. A verification link proves control of the mailbox, not possession of the second factor, so POST /v1/auth/verify-email returns the same mfa_required challenge rather than a session when the account has one enrolled. The email is marked verified either way — only the session waits for the second factor. This is reachable because consuming a magic link marks the email verified and issues a session of its own, so an account can enroll MFA while its original signup-verification link is still live.

Step-up reauth

Disabling MFA and regenerating recovery codes require a fresh MFA proof. When a gated route is called without a fresh proof, it returns:

403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://errors.driftstack.dev/mfa-step-up-required",
  "title": "MFA step-up required",
  "status": 403,
  "detail": "This action requires a fresh MFA challenge. ...",
  "requires_mfa_step_up": true,
  "reason": "never_satisfied"
}

reason is "never_satisfied" when the calling session has never passed an MFA challenge (e.g. a session issued by signup-verify before MFA was enrolled), and "expired" when the freshness window (15 minutes) has elapsed since the last successful challenge.

The client posts a 6-digit (or recovery) code to:

POST /v1/auth/mfa/step-up

{ "code": "123456" }

Response (200):

{
  "via": "totp",
  "mfa_satisfied_at": "2026-05-08T22:34:11.000Z"
}

The mfa_satisfied_at field on the calling session is updated to “now”; gated routes pass for the next 15 minutes. The original gated request can be retried.

The generic step-up middleware has a machine-auth carve-out because an API key has no human session to refresh. MFA credential-management routes do not rely on that carve-out: they reject API-key bearers before step-up evaluation. POST /v1/auth/mfa/step-up itself also returns 403 for an API key because there is no session row to refresh.

Disabling

DELETE /v1/account/mfa

The same operation is also exposed as POST /v1/account/mfa/disable — an alias route with the same body, gates, and semantics.

{ "confirm": "disable-mfa" }

Both endpoints require:

  1. Interactive web-session bearer; API-key bearers are rejected even when the key has account_owner.
  2. A fresh MFA proof (15-minute window). On stale, the response is the 403 step-up envelope above.
  3. The confirm: "disable-mfa" body field — defensive layer beneath the gate so a stray client request can’t accidentally disable.

Response: 204 No Content. Idempotent on already-disabled accounts.

Disabling clears the TOTP secret AND every unused recovery code. Re-enabling requires the full enrollment dance from scratch.

Recovery code regeneration

POST /v1/account/mfa/recovery-codes/regenerate

No body. Marks every prior unused code consumed; mints 10 new codes; returns them in the same shape as the enrollment response:

Response (200):

{
  "recovery_codes": [
    "WERTY-PASDF",
    ...
  ]
}

This endpoint is step-up gated, the same as MFA disable: a stale web session returns the 403 step-up envelope above (requires_mfa_step_up: true). Without the gate a stolen web session could mint fresh recovery codes, then redeem one to satisfy step-up on disable — a full MFA bypass. The legitimate lost-device-but-logged-in flow still works: an existing recovery code satisfies POST /v1/auth/mfa/step-up before regenerating. API-key callers are rejected before the step-up gate and cannot replace the human account’s recovery credentials.

Returns 404 Not Found when the calling account isn’t enrolled.

Algorithm details

Field Value
Algorithm SHA-1
Period 30 seconds
Digits 6
Drift tolerance ±1 window (90s total)
Issuer Driftstack
At-rest encryption AES-256-GCM (env-keyed)
Recovery code shape 10 chars, Crockford base32
Recovery code count 10 per enrollment / regenerate
Recovery code hash scrypt-kdf (same as API keys)
Challenge token TTL 5 minutes
Step-up freshness 15 minutes

SHA-1 is the RFC 6238 default and what every authenticator app (Google Authenticator, 1Password, Authy, Bitwarden, etc.) supports. SHA-256/SHA-512 are out of scope for v1.

Audit trail

Every MFA lifecycle event lands in the customer audit log (GET /v1/account/audit-log):

Action When
account.mfa_enrolled First successful /verify
account.mfa_disabled Successful disable
account.recovery_code_used Recovery code consumed (login or step-up)
account.login Successful challenge exchange (with method: mfa_totp or mfa_recovery payload)