D driftstack 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 (disabling MFA; future: account deletion) 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.

Enrollment

1. Start enrollment

POST /v1/account/mfa/enroll

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.

2. Confirm + receive recovery codes

POST /v1/account/mfa/verify

{
  "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

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 sign-in, password-reset confirm, and email-verification do NOT trigger the MFA challenge. Those flows prove ownership of the email address via single-use link; the MFA gate fires at the next /v1/auth/login.

Step-up reauth

Some operations require a fresh MFA proof (currently: disabling MFA; the locked V-353a verdict adds account-deletion when self-service deletion ships). 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.

API-key callers (machine-to-machine) bypass the step-up gate entirely — MFA is a human-factor concept; programmatic access uses scope-based authorization. POST /v1/auth/mfa/step-up itself returns 403 when called with an API key bearer (no session row to refresh).

Disabling

DELETE /v1/account/mfa (or POST /v1/account/mfa/disable)

{ "confirm": "disable-mfa" }

Both endpoints require:

  1. Web-session bearer (API-key callers bypass the step-up gate but still need admin scope on the calling key).
  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",
    ...
  ]
}

Per the V-353a Q3 verdict, this endpoint is NOT step-up gated — regenerating recovery codes is recoverable (the customer can rotate again if compromised); only disabling + account-delete are gated.

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

Algorithm details

FieldValue
AlgorithmSHA-1
Period30 seconds
Digits6
Drift tolerance±1 window (90s total)
IssuerDriftstack
At-rest encryptionAES-256-GCM (env-keyed)
Recovery code shape10 chars, Crockford base32
Recovery code count10 per enrollment / regenerate
Recovery code hashscrypt-kdf (same as API keys)
Challenge token TTL5 minutes
Step-up freshness15 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):

ActionWhen
account.mfa_enrolledFirst successful /verify
account.mfa_disabledSuccessful disable
account.recovery_code_usedRecovery code consumed (login or step-up)
account.loginSuccessful challenge exchange (with method: mfa_totp or mfa_recovery payload)