A user signs in at 9 AM with password and TOTP. At 3 PM, a colleague borrows their unlocked laptop and clicks “remove member”. The click happens while the session is still valid — login-time MFA proves who signed in; it cannot answer whether the person operating right now is still that user. The longer the session lives and the more devices are shared, the wider this gap grows.
Sensitive operation re-authentication (Step-Up) narrows identity verification from “once per session” to “once per sensitive operation”: a business endpoint declares that it needs re-authentication, a global middleware verifies a short-lived re-authentication credential before the request reaches business code, and a missing credential produces a structured 403 that the frontend turns into a unified verification dialog. After verification the request is replayed with the credential attached.
Step-Up vs login MFA
Both reuse factor kernels (TOTP, OTP, recovery codes), but they answer different assurance questions. NIST SP 800-63B (SP 800-63B) grades authentication assurance as AAL1–AAL3 (§4.3 “Authentication Assurance Levels”) and leaves session management and re-authentication cadence to deployment-level risk decisions — the standard does not contain any clause such as “sensitive-operation re-authentication must be at most 15 minutes”. The 300 s / 900 s values in this manual are platform factory defaults, to be tuned against observation data.
| Dimension | Login MFA (two-factor) | Step-Up re-authentication |
|---|---|---|
| Question answered | Who signed in | Is the operator still the user |
| Verification moment | After password, before token issuance | Inside a valid session, before the sensitive operation |
| Verification artifact | Five-minute login challenge token | A per-operation re-authentication credential |
| Enforced by | LoginFlow / MFA endpoints | Global StepUpEnforcementMiddleware |
| Coverage | One sign-in | Any permission-code purpose in the frozen catalog |
| Failure semantics | No access token | Structured 403; the session itself is untouched |
The two also cooperate: MFA changes are themselves sensitive operations. Disabling MFA, deleting a FIDO2 credential, or revoking a trusted device requires re-authentication, so a stolen session cannot silently dismantle its own protection. Conversely, a successful MFA change publishes UserMfaChangedIntegrationEvent, which revokes all outstanding re-authentication credentials of that subject.
Industry landscape
| System | Approach | Comparison |
|---|---|---|
| GitHub sudo mode | Sensitive operations require a recent password entry | Same “per-operation re-auth” idea; this platform swaps password-only for a pluggable factor set |
| Google sensitive-action verification | Critical changes ask for password or a second factor | Same; Google enumerates actions, this platform keys on purpose codes (permission codes) |
| AWS MFA-protected API | API calls carry a GetSessionToken temporary credential | Same “credential travels with the request”; this platform replays via a dedicated header |
| Azure AD Claims Challenge | Server-driven challenge asking the client to re-authenticate | Same server-initiated on-demand model; challenge info travels in 403 extension fields |
OAuth has a standardized track: RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol) defines in §3 “Authentication Requirements Challenge” how a resource server uses 401 + WWW-Authenticate: Bearer error="insufficient_user_authentication" with acr_values / max_age parameters. This platform intentionally deviates: it serves first-party SPAs with no redirect loop and no multi-authorization-server interop, so it chooses 403 + RFC 9457 ProblemDetails extension fields (purpose, factors, windowSeconds) for a richer guidance payload. Interop boundary: third parties integrating against this platform implement the 403 contract, not RFC 9470 client logic.
Architecture: one enforcement point, three sources
The core design fits in one sentence: endpoints declare intent, the global middleware enforces it. Declaration and enforcement are separated; endpoints carry zero step-up filters and the frontend sees exactly one behavior.
Reading the diagram (verified against src/Hosts/BitzOrcas.Api/StepUp/StepUpEnforcementMiddleware.cs in the BitzOrcasVNext repository):
- Purpose resolution is a single in-memory lookup (step 2). A
RequireStepUpAttributedeclaration on the endpoint wins; otherwise the endpoint permission code is used, and only if that code can possibly be enforced (present in the frozen catalog). Requests outside the enforcement surface cost nothing but one dictionary hit — no database, no Redis. - Policy evaluation can exempt (step 3). Feature toggle off, purpose not enforced, machine callers, and AuditOnly observation all pass here; observation additionally writes a
StepUpAuditOnlyObservedsecurity audit — the data source for gradual rollout. - The trust boundary is credential validation (steps 18–20). Credentials bind tenant + subject + purpose + revocation version; the Redis version counter increments on logout, password change, and MFA change, instantly invalidating outstanding credentials. Any storage failure yields a “policy unavailable” 403 (fail-closed), never a silent pass.
- The challenge loop is a separate endpoint family (steps 6–16,
/api/identity/step-up/*) that skips step-up interception itself; machine callers and restricted sessions (must-change-password, MFA enrollment tokens) are rejected on that path — defense in depth consolidated at the single enforcement point rather than scattered across endpoints.
The middleware’s pipeline position is guaranteed by the composition root (src/Hosts/BitzOrcas.Api/Program.cs): authentication → delegation token → forced password change → MFA enrollment → tenant resolution → session validation → permission enrichment → authorization → current-user scope → Step-Up enforcement → rate limiting. It must run after permission enrichment (it consumes enriched permission codes) and the restricted-session middlewares (no duplicate rulings), and before rate limiting (guidance 403s should not consume business rate-limit budget).
The three-layer policy model
Whether a purpose is enforced, with which window and factor set, is decided by the evaluator with a fixed precedence (src/Platform/Identity/BitzOrcas.Identity.Application/Identity/StepUp/StepUpPolicyEvaluator.cs):
Machine callers (Application/System/MCP personal tokens) -> exempt outrightFeature toggle identity.stepup off -> exempt outright (zero behavior)Layer 1: policy rows (tenant rows win over host baseline rows)Layer 2: declared baseline ([RequireStepUp] on the endpoint)Layer 3: neither rows nor baseline -> no-policy exemptionPolicy rows are the configurable data surface. Multiple rows can exist per purpose and are resolved by two rules:
- Across specificity levels: only the highest matching level counts. Specificity descends from Subjects (specific members) > Roles > Permissions > AllUsers. Example: with both an “AllUsers, Required, 900 s” row and a “member A, Off” row, member A matches the Off row (highest specificity) and is exempt — the standard “personal exemption overrides global enforcement” shape.
- Within the same level: strictest wins. Enforcement Required > AuditOnly > Off; ties take the smallest window; factor sets intersect.
Policy rows themselves pass three write-time guardrails: the purpose must exist in the startup-frozen catalog; tenant rows cannot exceed the host-controlled window ceiling; when the tenant-configurable surface is narrowed to a whitelist, purposes outside it are rejected; mandatory purposes only tighten (never Off/AuditOnly, window within baseline, factors within the baseline factor union). Violations return Identity.StepUp.PolicyConflict with a per-field reason in detail.
The model already has production instances: operations.sql-masking.manage (SQL masking rule management) carries a mandatory + one-shot baseline from the composition-root code declaration list — no tenant can downgrade it to observation; API-key and HMAC-client lifecycle commands, by contrast, are driven entirely by tenant policy rows with zero code markers, the production demonstration of the configuration-driven surface.
Factors and verification kernels
Step-Up invents no new verifiers; all six factors reuse the existing Identity kernels (registration, clock drift, and clone-detection semantics: see two-factor):
| Factor | Wire name | Verification kernel | Applicability |
|---|---|---|---|
| TOTP authenticator | totp | MFA connector TOTP validation | Users with a bound authenticator; default baseline factor |
| Passkey | fido2 | WebAuthn assertion (W3C Web Authentication Level 3) | Planned: contract placeholder; admin UI and dialog do not render it yet |
| Email OTP | emailOtp | Email OTP service | Fallback for users without MFA; masked target l***@example.com |
| SMS OTP | smsOtp | SMS OTP service | Same constraints; SIM-swap risk, transitional only |
| Password | password | Password hash re-verification | Accounts with passwords; lowest interaction cost |
| Recovery code | recoveryCode | Atomic recovery-code consumption (TryConsume) | Self-service recovery after losing a device |
TOTP builds on RFC 6238 (TOTP: Time-Based One-Time Password Algorithm), whose §5.2 discusses verification windows under clock drift; HOTP builds on RFC 4226. This platform does not redefine those algorithm parameters — clock policy and drift windows follow the MFA connector implementation.
Walkthrough: self-recovery after losing a device
Product manager Chen has only a TOTP authenticator, which is water-damaged and away for repair. She still must confirm a payment (a policy row that allows recoveryCode):
- She clicks confirm; the frontend receives a guidance 403 and the dialog lists “recovery code” among the factors;
- She types one code from her password manager — single-line input, no resend button, case-insensitivity hinted;
- The server atomically consumes that code (
TryConsume; at most one success under concurrency) and issues the credential; - The frontend toasts “recovery code consumed” and the request replays successfully;
- The account-security “step-up” card shows the remaining count; below 3 it shows a low-stock warning linking to regeneration.
If the recovery codes are exhausted too, the remaining path is the administrator-side recovery flow (verify ownership, revoke trusted devices, rotate codes) — never any client-reachable “skip verification”.
Security semantics
Fail-closed list — these always reject, never degrade to a pass:
- Policy snapshot read failure (Redis / database down) → 403
Identity.StepUp.PolicyUnavailable; - Credential store failure → same (caught uniformly by the middleware, except cancellation and OOM);
- Credential valid but its version key missing (deleted externally within TTL) → 403
GrantInvalid+ high-severity auditreason=versionKeyMissing; - Machine callers or restricted sessions touching the challenge loop → 403
ChallengeNotAllowed.
Subject version revocation and TTL boundaries: every (tenant, subject) pair has a revocation version counter; credentials record the version at issuance and fail validation on mismatch. Logout, password change, and MFA change events increment the counter, instantly invalidating outstanding credentials. The counter TTL is computed as “window ceiling + challenge TTL + safety padding” (factory 900 + 120 + 60 = 1080 s), guaranteeing that any credential that could still be alive always has a live version key — which is why a missing version key is an attack signal, not a normal state.
TOCTOU and the check-act boundary: credential validation happens in middleware, business execution in the handler — there is no atomic transaction spanning the two. One-shot credentials are consumed on first use; if the business operation then fails, the credential is not restored — a deliberate trade: zero replay window beats retry convenience, and the cost of a burned ticket is one re-verification. Window guidance: low-frequency high-severity operations (payments, permission changes) use one-shot (window 0) or short windows; high-frequency batch flows use 300–900 s windows so users are not interrupted repeatedly.
Frontend red line: re-authentication credentials live only in tab memory and must never be written to localStorage / sessionStorage — a refresh clears them naturally and the XSS exposure is minimized. Details in frontend integration.
Manual map
| Page | Type | Content |
|---|---|---|
| Quickstart | Tutorial | Three paths to wire an endpoint from zero, with expected failures |
| Contract reference | Reference | Endpoints, request/response JSON, 403 fields, full error table, configuration keys |
| Frontend integration | Guide | Interceptor replay guard, dialog state machine, SDK usage |
| Rollout and operations | Guide | AuditOnly-first rollout, threshold tuning, runbook, troubleshooting |
Related topics: Two-factor authentication · Authorization · Impersonation