MFA & Passkeys

TOTP-based multi-factor authentication and WebAuthn passkeys. TOTP enrollment, passkey registration, and credential management require a logged-in user (Bearer access token). The step-up verification endpoints (/mfa/verify, /passkey/login/*) are reached mid-login and require only the tenant header.

Authentication

All endpoints require the X-Bulwark-Tenant: <tenantId> header.

  • Management endpoints (enroll, confirm, status, register, list/rename/delete credentials, regenerate backup codes) additionally require Authorization: Bearer <accessToken>.
  • Mid-login endpoints (POST /mfa/verify, POST /passkey/login/begin, POST /passkey/login/finish) do not require a JWT — the user is in the middle of authenticating and has no access token yet.

TOTP (Authenticator Apps)

Enroll TOTP

POST /api/v1/auth/mfa/totp/enroll

Begins TOTP enrollment. The returned secret / otpauth_uri is shown to the user once (render otpauth_uri as a QR code). Enrollment is not active until confirmed.

Headers

  • Authorization: Bearer <accessToken>

Body

{
  "device_name": "iPhone Authenticator"
}

device_name is optional (defaults to "default").

Response 201

{
  "credential_id": "cred_01j...",
  "secret": "JBSWY3DPEHPK3PXP",
  "otpauth_uri": "otpauth://totp/Bulwark:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Bulwark"
}

Errors

  • 409 — TOTP already enrolled and active.

Confirm TOTP

POST /api/v1/auth/mfa/totp/confirm

Activates enrollment by verifying a code from the authenticator. On success, returns 10 single-use backup codes (shown once — the user must save them).

Headers

  • Authorization: Bearer <accessToken>

Body

{
  "credential_id": "cred_01j...",
  "code": "123456"
}

Response 200

{
  "backup_codes": [
    "a1b2c3d4e5f6a1b2c3d4",
    "f6e5d4c3b2a1f6e5d4c3"
  ]
}

Ten codes, 20 hex characters each (80 bits of entropy). Stored hashed; never retrievable again.

Errors

  • 401 — invalid code.
  • 404 — credential not found.
  • 409 — already confirmed.

Remove TOTP Device

DELETE /api/v1/auth/mfa/totp/{id}

Removes a confirmed TOTP device. If it was the last one, all backup codes are deleted too.

Headers

  • Authorization: Bearer <accessToken>

Response 200

{ "status": "removed" }

Verify MFA (mid-login)

POST /api/v1/auth/mfa/verify

Completes a login that requires a second factor. Called with the challenge_id returned when a login is flagged for MFA. On success, issues an access token. No JWT required.

Body

{
  "challenge_id": "chl_01j...",
  "code": "123456",
  "method": "totp"
}

method is "totp" or "backup_code". Backup codes are consumed (single-use) on success.

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "expires_in": 900
}

Errors

  • 401 — invalid code.
  • 410 — challenge expired.
  • 409 — challenge already used.
  • 429 — too many attempts.

MFA Status

GET /api/v1/auth/mfa/status

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "totp_enabled": true,
  "totp_devices": [
    { "id": "cred_01j...", "device_name": "iPhone Authenticator", "created_at": "2026-06-01T12:00:00Z" }
  ],
  "backup_codes_count": 8,
  "mfa_policy": "optional"
}

mfa_policy is the tenant's policy: "disabled", "optional", or "required".


Regenerate Backup Codes

POST /api/v1/auth/mfa/backup-codes

Deletes existing backup codes and returns 10 fresh ones. Requires confirmed TOTP.

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "backup_codes": ["a1b2c3d4e5f6a1b2c3d4", "..."]
}

Errors

  • 400 — TOTP not enrolled.

Passkeys (WebAuthn)

Passkey flows follow the standard WebAuthn begin/finish pattern. The options object in a begin response is the W3C PublicKeyCredential options — pass options.publicKey straight to navigator.credentials.create() (registration) or navigator.credentials.get() (login). Send the browser's resulting credential JSON as the raw request body of the matching finish call, with the challenge_id from begin as a query parameter.

Begin Passkey Login (mid-login)

POST /api/v1/auth/passkey/login/begin

Starts a discoverable (usernameless) passkey login. No JWT required. May be rate-limited.

Response 200

{
  "challenge_id": "chl_01j...",
  "options": {
    "publicKey": {
      "challenge": "<base64url>",
      "timeout": 60000,
      "userVerification": "required",
      "allowCredentials": []
    }
  }
}

Finish Passkey Login (mid-login)

POST /api/v1/auth/passkey/login/finish?challenge_id=chl_01j...

Body is the raw WebAuthn assertion JSON from navigator.credentials.get(). On success, issues an access token (and a refresh token + session when sessions are enabled).

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900
}

Errors

  • 403 — account not active.
  • 404 — no passkeys registered for the resolved user, or challenge not found.
  • 410 — challenge expired.

Begin Passkey Registration

POST /api/v1/auth/webauthn/register/begin

Starts registration of a new passkey for the logged-in user. Already-enrolled credentials are excluded to prevent duplicates.

Headers

  • Authorization: Bearer <accessToken>

Body

{
  "friendly_name": "iPhone 15 Face ID"
}

friendly_name is optional.

Response 200

{
  "challenge_id": "chl_01j...",
  "options": {
    "publicKey": {
      "challenge": "<base64url>",
      "rp": { "name": "Bulwark", "id": "bulwarkauth.com" },
      "user": { "id": "<base64url>", "name": "[email protected]", "displayName": "Ada Lovelace" },
      "pubKeyCredParams": [],
      "timeout": 60000,
      "attestation": "none",
      "authenticatorSelection": { "residentKey": "preferred", "userVerification": "preferred" },
      "excludeCredentials": []
    }
  }
}

Errors

  • 409 — maximum number of credentials reached.

Finish Passkey Registration

POST /api/v1/auth/webauthn/register/finish?challenge_id=chl_01j...

Body is the raw WebAuthn attestation JSON from navigator.credentials.create(). An optional friendly_name query parameter overrides the name supplied at begin.

Headers

  • Authorization: Bearer <accessToken>

Response 201

{
  "id": "cred_01j...",
  "friendly_name": "iPhone 15 Face ID",
  "authenticator_type": "platform",
  "is_discoverable": true,
  "backup_eligible": true,
  "created_at": "2026-06-01T12:00:00Z"
}

List Passkeys

GET /api/v1/auth/webauthn/credentials

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "credentials": [
    {
      "id": "cred_01j...",
      "friendly_name": "iPhone 15 Face ID",
      "authenticator_type": "platform",
      "is_discoverable": true,
      "backup_eligible": true,
      "backup_state": true,
      "last_used_at": "2026-06-01T11:00:00Z",
      "created_at": "2026-05-20T09:00:00Z"
    }
  ]
}

Rename Passkey

PATCH /api/v1/auth/webauthn/credentials/{id}

Headers

  • Authorization: Bearer <accessToken>

Body

{ "friendly_name": "Work Laptop" }

Response 200

{ "status": "updated" }

Delete Passkey

DELETE /api/v1/auth/webauthn/credentials/{id}

Headers

  • Authorization: Bearer <accessToken>

Response 200

{ "status": "deleted" }