Appearance
Multi-Factor Authentication (MFA)
GrydAuth v5 adds multi-factor authentication: time-based one-time passwords (TOTP, RFC 6238) from any authenticator app, single-use recovery codes, and step-up enforcement governed by a per-tenant policy. MFA is opt-in — a tenant with the Disabled policy behaves exactly as before — so it can be rolled out gradually.
Upgrading from v4?
See the v4 → v5 migration guide for the minimum configuration needed to turn MFA on and the client changes it requires.
Concepts
Factor — a second authentication method bound to a user. v5 ships TOTP; the factor model is extensible (a WebAuthn/passkey factor type is reserved for a future release). A factor is created in a pending state during enrollment and becomes active only after the user confirms it with a valid code.
Recovery codes — single-use backup codes issued when TOTP is confirmed, for when the authenticator device is unavailable. They are shown once, stored only as SHA-256 hashes, and consumed on use.
Step-up / mfa_pending token — when a login needs a second factor, GrydAuth issues a short-lived token with token_type = mfa_pending (and no refresh token) instead of full tokens. That token is accepted only on the MFA challenge endpoints, where the user proves a factor to receive full tokens. An active session can also request a fresh mfa_pending token via POST /mfa/step-up/begin to re-prove MFA without logging in again.
Enrollment bootstrap / mfa_enrollment token — when a login requires MFA but the user has no usable factor yet (no enabled factor and no active recovery code), GrydAuth issues a short-lived token with token_type = mfa_enrollment (tokenType = "MfaEnrollment" in the response body) instead of mfa_pending. That token is accepted only on the TOTP enrollment endpoints, where the user registers the first factor; confirming it elevates the session to full tokens — no second login needed. See Onboarding bootstrap.
Policy mode — the effective requirement per tenant:
| Mode | Behaviour |
|---|---|
Disabled | MFA never required (v4-equivalent). |
RiskBased (default) | MFA required only when Zero Trust flags the sign-in. |
Always | MFA required on every login. |
Configuration
jsonc
"GrydAuth": {
"Mfa": {
"SecretProtection": {
"ActiveKeyId": "mfa-key-2026-01",
"Keys": { "mfa-key-2026-01": "${MFA_SECRET_KEY_BASE64URL}" }
},
"Totp": {
"Issuer": "Gryd.IO",
"Digits": 6,
"PeriodSeconds": 30,
"WindowSteps": 1,
"Algorithm": "SHA1",
"MaxFailedAttempts": 5,
"LockoutDuration": "00:15:00"
}
},
"MfaManagement": { "RecentStepUpMinutes": 10 },
"MfaEnrollment": { "TokenLifetimeMinutes": 10 },
"MfaPolicy": { "DefaultMode": "RiskBased", "CacheDurationMinutes": 5 }
}Secret protection (GrydAuth:Security:SecretProtection)
TOTP seeds are encrypted at rest by the module's shared secret-protection subsystem — the same one that protects enterprise IdP client secrets, which is why the section is not under Mfa.
The host does not start without it, in any environment (there is no Development exemption), so this is a prerequisite for running the module at all, not just for enabling MFA.
bash
# Generate a 32-byte Base64URL key
openssl rand 32 | basenc --base64url | tr -d '='Full configuration reference, the rotation runbook and the SECRET_PROTECTION_* error codes: secret-protection.md.
TOTP (GrydAuth:Mfa:Totp)
| Property | Default | Constraint |
|---|---|---|
Issuer | Gryd.IO | Label shown in the authenticator app; ≤ 100 chars. |
Digits | 6 | 6 or 8. |
PeriodSeconds | 30 | 15–120. |
WindowSteps | 1 | 0–2 — clock-skew tolerance; also bounds the anti-replay window. |
Algorithm | SHA1 | SHA1 / SHA256 / SHA512 (SHA1 is the widest-compatible). |
MaxFailedAttempts | 5 | 3–10 — failures before lockout. |
LockoutDuration | 00:15:00 | 1 min – 1 hour. |
A verified code cannot be replayed within its window, and repeated failures trigger a temporary lockout per MaxFailedAttempts / LockoutDuration.
Reauthentication window (GrydAuth:MfaManagement)
| Property | Default | Constraint |
|---|---|---|
RecentStepUpMinutes | 10 | 1–60. |
Sensitive MFA operations (removing a factor, regenerating recovery codes, disabling MFA) require a recent successful MFA. On a successful challenge, GrydAuth stamps a mfa_verified_at claim; the RequireRecentMfa guard allows those operations only while now − mfa_verified_at ≤ RecentStepUpMinutes.
When the claim is missing or stale, the request is rejected with 403 ProblemDetails carrying errorCode = MFA_STEP_UP_REQUIRED and a stepUpUrl hint (/mfa/step-up/begin) — a standardized signal the client uses to trigger the in-session step-up and retry the original request.
Enrollment token (GrydAuth:MfaEnrollment)
| Property | Default | Constraint |
|---|---|---|
TokenLifetimeMinutes | 10 | 1–15. |
Lifetime of the mfa_enrollment token issued at login when MFA is required but the user has no usable factor. Short by design: it only needs to cover scanning a QR code and typing the first TOTP code.
Policy (GrydAuth:MfaPolicy)
| Property | Default | Constraint |
|---|---|---|
DefaultMode | RiskBased | Always / RiskBased / Disabled. |
CacheDurationMinutes | 5 | 1–60 — how long a tenant's resolved policy is cached. |
DefaultMode is the fallback for tenants that have not set an explicit policy.
Enrollment (TOTP)
The enrollment endpoints accept an access token (settings flow — a signed-in user adding a factor) or an mfa_enrollment token (onboarding bootstrap — see below). mfa_pending tokens are rejected on these endpoints.
Begin — the user starts enrollment:
httpPOST /api/v1/mfa/totp/enroll/begin Authorization: Bearer <access token or mfa_enrollment token> Content-Type: application/json { "friendlyName": "iPhone" }Response returns the provisioning material:
json{ "otpAuthUri": "otpauth://totp/Gryd.IO:user@example.com?secret=...&issuer=Gryd.IO&algorithm=SHA1&digits=6&period=30", "manualKey": "JBSWY3DPEHPK3PXP" }Render
otpAuthUrias a QR code, or let the user typemanualKeyinto their app. The factor is pending at this point.Confirm — the user submits a current code to activate the factor:
httpPOST /api/v1/mfa/totp/enroll/confirm Authorization: Bearer <access token or mfa_enrollment token> Content-Type: application/json { "code": "123456" }On success the factor becomes active and GrydAuth returns the user's recovery codes. On the settings path (access token)
authenticationisnull:json{ "recoveryCodes": ["a1B2c3D4e5F6g7H8i9J0k1", "..."], "authentication": null }On the onboarding bootstrap path (
mfa_enrollmenttoken) the confirmation also elevates the session:authenticationcarries a normalAuthenticationResponse(full Tenant token with a freshmfa_verified_at; the refresh token travels in the HttpOnly cookie, never in the body):json{ "recoveryCodes": ["..."], "authentication": { "token": "…", "tokenType": "Tenant", "…": "…" } }Show recovery codes once
Recovery codes are returned only here (and on regeneration). Prompt the user to store them securely; they cannot be retrieved again.
Onboarding bootstrap (first factor at login)
Under the Always policy (or RiskBased when the sign-in is flagged), a user with no usable factor would otherwise be locked out: mfa_pending only works on the challenge endpoints, and the challenge fails with MFA_FACTOR_NOT_FOUND. Instead, login detects the missing factor and returns tokenType = "MfaEnrollment" with a short-lived mfa_enrollment token (no refresh token, no permissions, currentTenant set) and records the mfa_enrollment_required audit event. The client then completes the bootstrap without a second login:
- Detect
tokenType === "MfaEnrollment"in the login response and route to the MFA setup UI. POST /mfa/totp/enroll/beginwith the enrollment token → QR code / manual key.POST /mfa/totp/enroll/confirmwith the first TOTP code → factor active, recovery codes, andauthenticationwith the full Tenant session (cookies included).
Security properties: the enrollment token is accepted only on the two enrollment endpoints (401/403 anywhere else), carries no permissions, expires in TokenLifetimeMinutes, and is refused (MFA_FACTOR_ALREADY_EXISTS) if the user already has an enabled factor. The elevation requires a valid TOTP code — there is no free session upgrade.
Login with MFA (step-up)
When a tenant policy requires MFA and the user has a usable factor, login does not return full tokens. Instead the AuthenticationResult carries token_type = mfa_pending, no refresh token, and the list of available factors (e.g. totp, recovery_code). (Without a usable factor, login returns MfaEnrollment instead — see Onboarding bootstrap.) The client then completes a challenge:
Begin the challenge using the
mfa_pendingtoken:httpPOST /api/v1/mfa/challenge/begin Authorization: Bearer <mfa_pending token>json{ "availableFactors": ["Totp", "RecoveryCode"] }Verify a factor to receive full tokens:
httpPOST /api/v1/mfa/challenge/verify Authorization: Bearer <mfa_pending token> Content-Type: application/json { "factorType": "Totp", "code": "123456" }The response embeds a normal
AuthenticationResult(access token, refresh token, tenant info) — the sign-in is now complete. Pass"factorType": "RecoveryCode"with a recovery code to authenticate when the authenticator is unavailable.
Token type enforcement
The mfa_pending token is rejected on every endpoint except challenge/begin and challenge/verify; the mfa_enrollment token is rejected on every endpoint except totp/enroll/begin and totp/enroll/confirm; and full-access tokens are rejected on the challenge endpoints. See canonical token_type.
In-session step-up (/mfa/step-up/begin)
Sensitive actions guarded by RequireRecentMfa demand a fresh mfa_verified_at claim. When it is missing or older than RecentStepUpMinutes, the action fails with:
http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{ "status": 403, "detail": "MFA step-up is required.",
"code": "MFA_STEP_UP_REQUIRED", "stepUpUrl": "/mfa/step-up/begin", "…": "…" }Instead of forcing a new login, the client re-proves MFA in-session:
Begin the step-up with the active session token:
httpPOST /api/v1/mfa/step-up/begin Authorization: Bearer <access token>json{ "token": "<mfa_pending token>", "expiresAt": "2026-07-16T12:05:00Z", "availableFactors": ["Totp", "RecoveryCode"] }Returns
404 MFA_FACTOR_NOT_FOUNDwhen the user has no usable factor. Each request is rate-limited (mfapolicy) and audited asstep_up_required.Run the normal challenge with the returned
mfa_pendingtoken (challenge/begin+challenge/verify). The verify issues a fresh Tenant token with a renewedmfa_verified_at.Retry the original sensitive request with the new token.
The step-up grants no extra privilege — it only lets an already-authenticated user re-prove MFA.
Recovery codes
Recovery codes are single-use backups issued at TOTP confirmation. Each is ~22 characters (Base64URL), stored only as a SHA-256 hash, and invalidated once used. The user can mint a fresh set (invalidating the old one) — this requires recent MFA:
http
POST /api/v1/mfa/recovery-codes/regenerate
Authorization: Bearer <access token> # must have recent MFAjson
{ "recoveryCodes": ["...", "..."] }By default a set contains 10 codes.
Managing factors
http
GET /api/v1/mfa/factors
Authorization: Bearer <access token>Lists the user's factors:
json
[
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "Totp",
"status": "Enabled",
"friendlyName": "iPhone",
"confirmedAt": "2026-06-22T12:00:00Z",
"lastUsedAt": "2026-07-01T09:30:00Z"
}
]Removing a factor, regenerating recovery codes, and disabling MFA all require recent MFA (RequireRecentMfa) — a stale session gets 403 MFA_STEP_UP_REQUIRED and should run the in-session step-up:
http
DELETE /api/v1/mfa/factors/{id}
POST /api/v1/mfa/recovery-codes/regenerate
POST /api/v1/mfa/disable
Authorization: Bearer <access token> # recent MFA requiredWire contract: enums are strings
Every MFA enum travels as a string (PascalCase), never a number: factorType ("Totp"/"RecoveryCode"), availableFactors, factor type/status ("Totp"/"Pending"/"Enabled"), policy mode ("Always"/"RiskBased"/"Disabled"), and tokenType. Requests also accept the string names.
Administering tenant policy
Tenant administrators (permission admin:system or update:tenants) read and set the MFA mode for a tenant:
http
GET /api/v1/admin/tenants/{tenantId}/mfa-policyjson
{ "tenantId": "…", "mode": "RiskBased" }http
PUT /api/v1/admin/tenants/{tenantId}/mfa-policy
Content-Type: application/json
{ "mode": "Always" }Endpoint reference
All MFA endpoints are versioned under api/v{version} and rate-limited by the mfa policy.
| Method & path | Auth | Purpose |
|---|---|---|
POST /mfa/totp/enroll/begin | access or mfa_enrollment token | Start TOTP enrollment (returns otpAuthUri, manualKey). |
POST /mfa/totp/enroll/confirm | access or mfa_enrollment token | Confirm enrollment; returns recovery codes (+ authentication on the bootstrap path). |
POST /mfa/challenge/begin | mfa_pending token | List available factors for the pending sign-in. |
POST /mfa/challenge/verify | mfa_pending token | Verify a factor; returns full tokens. |
POST /mfa/step-up/begin | access token | Issue a fresh mfa_pending token to re-prove MFA in-session. |
GET /mfa/factors | access token | List the user's factors. |
DELETE /mfa/factors/{id} | access token + recent MFA | Remove a factor. |
POST /mfa/recovery-codes/regenerate | access token + recent MFA | Issue a new recovery-code set. |
POST /mfa/disable | access token + recent MFA | Disable MFA for the user. |
GET /admin/tenants/{tenantId}/mfa-policy | admin:system / update:tenants | Read tenant MFA mode. |
PUT /admin/tenants/{tenantId}/mfa-policy | admin:system / update:tenants | Set tenant MFA mode. |
Error codes
MFA failures use the standard ProblemDetails shape with code in the extensions:
errorCode | HTTP | Meaning |
|---|---|---|
MFA_STEP_UP_REQUIRED | 403 | mfa_verified_at missing/stale on a RequireRecentMfa action — run the step-up (stepUpUrl hint included). |
MFA_FACTOR_NOT_FOUND | 404 | No usable factor (challenge or step-up begin). |
MFA_FACTOR_ALREADY_EXISTS | 409 | Enrollment-token caller already has an enabled factor. |
MFA_TOTP_ENROLLMENT_NOT_FOUND | 404 | No pending enrollment to confirm. |
MFA_TOTP_CODE_INVALID | 400 | Wrong TOTP code on enrollment confirm. |
MFA_CODE_INVALID | 400 | Wrong or replayed code on challenge verify. |
MFA_CHALLENGE_LOCKED | 400 | Too many failed attempts; retry after LockoutDuration. |
MFA_TENANT_REQUIRED | 400 | Token lacks tenant context. |
MFA_AUTHENTICATION_REQUIRED | 400 | Caller identity missing. |
MFA_CONTEXT_NOT_FOUND | 404 | User/tenant unavailable when issuing the elevated session. |
Security notes
- Secrets encrypted at rest. TOTP seeds are never stored in plaintext and are excluded from audit/log output. The ciphertext is bound to the owning user and factor, so a seed copied to another row — or into another kind of secret's column — fails to decrypt instead of working. See secret-protection.md.
- Recovery codes are hashed (SHA-256), single-use, and shown only at issuance.
- Anti-replay & lockout protect the challenge (
WindowSteps,MaxFailedAttempts,LockoutDuration). - Reauthentication (
RequireRecentMfa) protects factor management from a stolen access token. - Roadmap: WebAuthn / passkeys (FIDO2) is a reserved factor type planned for a future release; the current release covers TOTP and recovery codes.
See also
- Authentication — token types and claims
- Security Features — token security and hardening
- v4 → v5 Migration — enablement and client changes