Skip to content

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:

ModeBehaviour
DisabledMFA never required (v4-equivalent).
RiskBased (default)MFA required only when Zero Trust flags the sign-in.
AlwaysMFA 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) ​

PropertyDefaultConstraint
IssuerGryd.IOLabel shown in the authenticator app; ≤ 100 chars.
Digits66 or 8.
PeriodSeconds3015–120.
WindowSteps10–2 — clock-skew tolerance; also bounds the anti-replay window.
AlgorithmSHA1SHA1 / SHA256 / SHA512 (SHA1 is the widest-compatible).
MaxFailedAttempts53–10 — failures before lockout.
LockoutDuration00:15:001 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) ​

PropertyDefaultConstraint
RecentStepUpMinutes101–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) ​

PropertyDefaultConstraint
TokenLifetimeMinutes101–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) ​

PropertyDefaultConstraint
DefaultModeRiskBasedAlways / RiskBased / Disabled.
CacheDurationMinutes51–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.

  1. Begin — the user starts enrollment:

    http
    POST /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 otpAuthUri as a QR code, or let the user type manualKey into their app. The factor is pending at this point.

  2. Confirm — the user submits a current code to activate the factor:

    http
    POST /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) authentication is null:

    json
    { "recoveryCodes": ["a1B2c3D4e5F6g7H8i9J0k1", "..."], "authentication": null }

    On the onboarding bootstrap path (mfa_enrollment token) the confirmation also elevates the session: authentication carries a normal AuthenticationResponse (full Tenant token with a fresh mfa_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:

  1. Detect tokenType === "MfaEnrollment" in the login response and route to the MFA setup UI.
  2. POST /mfa/totp/enroll/begin with the enrollment token → QR code / manual key.
  3. POST /mfa/totp/enroll/confirm with the first TOTP code → factor active, recovery codes, and authentication with 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:

  1. Begin the challenge using the mfa_pending token:

    http
    POST /api/v1/mfa/challenge/begin
    Authorization: Bearer <mfa_pending token>
    json
    { "availableFactors": ["Totp", "RecoveryCode"] }
  2. Verify a factor to receive full tokens:

    http
    POST /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:

  1. Begin the step-up with the active session token:

    http
    POST /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_FOUND when the user has no usable factor. Each request is rate-limited (mfa policy) and audited as step_up_required.

  2. Run the normal challenge with the returned mfa_pending token (challenge/begin + challenge/verify). The verify issues a fresh Tenant token with a renewed mfa_verified_at.

  3. 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 MFA
json
{ "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 required

Wire 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-policy
json
{ "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 & pathAuthPurpose
POST /mfa/totp/enroll/beginaccess or mfa_enrollment tokenStart TOTP enrollment (returns otpAuthUri, manualKey).
POST /mfa/totp/enroll/confirmaccess or mfa_enrollment tokenConfirm enrollment; returns recovery codes (+ authentication on the bootstrap path).
POST /mfa/challenge/beginmfa_pending tokenList available factors for the pending sign-in.
POST /mfa/challenge/verifymfa_pending tokenVerify a factor; returns full tokens.
POST /mfa/step-up/beginaccess tokenIssue a fresh mfa_pending token to re-prove MFA in-session.
GET /mfa/factorsaccess tokenList the user's factors.
DELETE /mfa/factors/{id}access token + recent MFARemove a factor.
POST /mfa/recovery-codes/regenerateaccess token + recent MFAIssue a new recovery-code set.
POST /mfa/disableaccess token + recent MFADisable MFA for the user.
GET /admin/tenants/{tenantId}/mfa-policyadmin:system / update:tenantsRead tenant MFA mode.
PUT /admin/tenants/{tenantId}/mfa-policyadmin:system / update:tenantsSet tenant MFA mode.

Error codes ​

MFA failures use the standard ProblemDetails shape with code in the extensions:

errorCodeHTTPMeaning
MFA_STEP_UP_REQUIRED403mfa_verified_at missing/stale on a RequireRecentMfa action — run the step-up (stepUpUrl hint included).
MFA_FACTOR_NOT_FOUND404No usable factor (challenge or step-up begin).
MFA_FACTOR_ALREADY_EXISTS409Enrollment-token caller already has an enabled factor.
MFA_TOTP_ENROLLMENT_NOT_FOUND404No pending enrollment to confirm.
MFA_TOTP_CODE_INVALID400Wrong TOTP code on enrollment confirm.
MFA_CODE_INVALID400Wrong or replayed code on challenge verify.
MFA_CHALLENGE_LOCKED400Too many failed attempts; retry after LockoutDuration.
MFA_TENANT_REQUIRED400Token lacks tenant context.
MFA_AUTHENTICATION_REQUIRED400Caller identity missing.
MFA_CONTEXT_NOT_FOUND404User/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 ​

Released under the MIT License.