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 toredirect_uriwith the tokens once the code is accepted. Without one, the JSON response carriesmfa_requiredandchallenge_id, as fromPOST /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
amrclaim containingmfaor two or more methods, or a SAMLAuthnContextClassRefsuch ashttp://schemas.microsoft.com/claims/multipleauthnorhttps://refeds.org/profile/mfa. Otherwise the sign-in is treated like a social one. Aredirect_uriused 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_policyfirst. A legacy top-levelmfa_policykey 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
/loginuses 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, andmethod("totp"or"backup_code"). - Enrolment (policy
"required", user has no MFA): the page shows a QR code and secret; form fieldsmfa_handleandcode(the 6-digit code from the new authenticator). On success the page shows 10 recovery codes once, and a Continue button postsmfa_handleandaction=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 theirtoken_versionhas 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" }