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: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 '='
PropertyDefaultNotes
ActiveKeyIdRequired; id of the key used to encrypt new secrets.
KeysMap of keyId → 32-byte Base64URL key.

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 secrets are never stored in plaintext; they are encrypted with the Mfa:SecretProtection key 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

Released under the MIT License.