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:Mfa:SecretProtection)
TOTP secrets are encrypted at rest. Supply at least one key; each key must be a 32-byte Base64URL value, and ActiveKeyId must reference a configured key. This is validated at boot whenever a policy could require MFA (relaxed only in Development when left empty). Multiple keys allow rotation: add a new key, point ActiveKeyId at it, and keep old keys for decryption.
bash
# Generate a 32-byte Base64URL key
openssl rand 32 | basenc --base64url | tr -d '='| Property | Default | Notes |
|---|---|---|
ActiveKeyId | — | Required; id of the key used to encrypt new secrets. |
Keys | — | Map of keyId → 32-byte Base64URL key. |
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 secrets are never stored in plaintext; they are encrypted with the
Mfa:SecretProtectionkey and excluded from audit/log output. - 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