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