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.

MFA policy

Each application (tenant) has an MFA policy, set in the dashboard or through the settings API as auth_methods.mfa_policy:

| Policy | User with confirmed TOTP | User without MFA | | --- | --- | --- | | "disabled" | Challenged | Signs in with the first factor | | "optional" (default) | Challenged | Signs in with the first factor | | "required" | Challenged | Must enrol (set up and confirm TOTP) before sign-in completes |

  • Enrolled users are always challenged. No policy value, including "disabled", skips the second factor for a user who has confirmed TOTP. "disabled" only means "don't force enrolment".
  • The policy applies identically on every interactive sign-in path: hosted /login, POST /api/v1/auth/login, /oauth2/authorize (both the password form and the login ticket), and social and enterprise SSO sign-ins.
  • Social sign-in (Google, GitHub): the provider's own MFA can't be verified, so an enrolled user gets the second-factor step exactly as after a password. With a redirect_uri, the browser is sent to the hosted MFA page, which returns to redirect_uri with the tokens once the code is accepted. Without one, the JSON response carries mfa_required and challenge_id, as from POST /api/v1/auth/login.
  • Enterprise SSO: Bulwark doesn't challenge again when the identity provider asserts it already did multi-factor authentication. That means an OIDC amr claim containing mfa or two or more methods, or a SAML AuthnContextClassRef such as http://schemas.microsoft.com/claims/multipleauthn or https://refeds.org/profile/mfa. Otherwise the sign-in is treated like a social one. A redirect_uri used with social or SSO sign-in must also be allowed for the application, because the hosted MFA page checks it.
  • Where it is read from: auth_methods.mfa_policy first. A legacy top-level mfa_policy key is used only when the nested one is empty. With neither set, the policy is "optional". An unrecognised value is treated as "optional".
  • The policy belongs to the application the user signs in to; the enrolment belongs to the user. For a shared application whose users live in another tenant's user pool, the application's own policy applies (the same tenant hosted /login uses for that app). Enrolment, challenges and backup codes live with the user, in the tenant that holds their account, though. A device enrolled through any application of the pool is honoured, and the user can manage it (status, removal, backup codes) through every other application of that pool. Once a user has confirmed TOTP, a new enrolment is refused through every application of the pool, not just the one they enrolled through. A challenge must be completed through the application that started it. The admin MFA reset is the exception: it works only for users whose account is stored in the admin's own tenant.
  • Fail closed: if the policy or the user's enrolment cannot be read, sign-in fails and no token or authorization code is issued.

Second factor during OAuth authorization

POST /oauth2/authorize/mfa

When a user who needs a second factor signs in at /oauth2/authorize (password or login ticket), Bulwark does not issue an authorization code. It shows a second-factor page instead, and the code is issued only after that page is completed. The consent screen (for clients that are not trusted) comes after the second factor, never instead of it. Relying parties need no changes: they still receive the redirect with code (and state) at the end.

The pages post HTML forms to this endpoint. It is a browser-flow endpoint, not a JSON API:

  • Challenge (user has TOTP): form fields mfa_handle, code, and method ("totp" or "backup_code").
  • Enrolment (policy "required", user has no MFA): the page shows a QR code and secret; form fields mfa_handle and code (the 6-digit code from the new authenticator). On success the page shows 10 recovery codes once, and a Continue button posts mfa_handle and action=continue.

The form action is /oauth2/authorize/mfa?<the original authorize query>. mfa_handle is an opaque value that exists only in the page's POST form. It is never placed in a URL or cookie. It refers to a server-side pending authorization with these properties:

  • Single use. Any submission consumes it, correct or not. A wrong code re-renders the page with a new handle. At most 5 wrong codes are accepted per sign-in; after that the user must sign in again. The challenge itself also allows 5 attempts.
  • Short-lived. It expires 10 minutes after the first factor, and a retry never extends that. The TOTP challenge itself expires after 5 minutes.
  • Re-checked on every use. Bulwark re-validates the original authorize request (client, redirect URI, PKCE, state, nonce, scopes). It also checks that the client and redirect URI in the form action match the handle, that the user is still active, and that their token_version has not changed (no password reset, suspension or session revocation since the first factor). Any failure shows the sign-in page with "Your sign-in session expired. Please sign in again." and issues no code.
  • Stores no secrets. The pending authorization never holds the password or the login ticket.
  • Requires Valkey. Without the store, users who need a second factor cannot finish signing in (fail closed). Users who need none are unaffected.

Responses carry Cache-Control: no-store and Referrer-Policy: no-referrer. The endpoint is rate limited like POST /oauth2/authorize. Audit events: login.mfa_challenge when a page is issued (and failure if the gate fails closed); login.mfa_verified with success or failure for each submission.

amr in the ID token

ID tokens from the authorization-code flow carry amr (RFC 8176):

| Sign-in | amr | | --- | --- | | Password only | ["pwd"] | | Password + TOTP or recovery code | ["pwd", "otp", "mfa"] | | Login ticket | ["pwd"] (the ticket stands in for the password the user just set) | | Login ticket + TOTP or recovery code | ["pwd", "otp", "mfa"] |

Access tokens do not carry amr.


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". See MFA policy.


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