Skip to content
bitzorcas
中EN

Concept

Sensitive Operation Re-authentication (Step-Up)

Login MFA cannot answer “is it still you right now?”. Step-Up re-authentication verifies identity per sensitive operation: one global enforcement middleware, a three-layer policy model, and six factors reusing existing verification kernels.

Last updated

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.

DimensionLogin MFA (two-factor)Step-Up re-authentication
Question answeredWho signed inIs the operator still the user
Verification momentAfter password, before token issuanceInside a valid session, before the sensitive operation
Verification artifactFive-minute login challenge tokenA per-operation re-authentication credential
Enforced byLoginFlow / MFA endpointsGlobal StepUpEnforcementMiddleware
CoverageOne sign-inAny permission-code purpose in the frozen catalog
Failure semanticsNo access tokenStructured 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

SystemApproachComparison
GitHub sudo modeSensitive operations require a recent password entrySame “per-operation re-auth” idea; this platform swaps password-only for a pluggable factor set
Google sensitive-action verificationCritical changes ask for password or a second factorSame; Google enumerates actions, this platform keys on purpose codes (permission codes)
AWS MFA-protected APIAPI calls carry a GetSessionToken temporary credentialSame “credential travels with the request”; this platform replays via a dedicated header
Azure AD Claims ChallengeServer-driven challenge asking the client to re-authenticateSame 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.

RedisChallenge endpointsIStepUpGrantServiceIStepUpPolicyEvaluatorStepUpEnforcementMiddlewareFirst-party SPARedisChallenge endpointsIStepUpGrantServiceIStepUpPolicyEvaluatorStepUpEnforcementMiddlewareFirst-party SPAPOST /api/business/sensitive (no credential)1Purpose resolution (attribute first, else permission code)2EvaluateAsync(purpose)3StepUpEnforced (factors + window)4403 type=step-up-requiredpurpose + factors + windowSeconds5POST /api/identity/step-up/challenge6Store challenge (TTL=120s)7challengeId + factors (masked targets)8POST .../otp/send (email/SMS factor)9resendAllowInSeconds countdown10POST .../verify (challengeId + factor + code)11Verify + issue credential (GET/SET + version key SETNX)12stepUpToken + windowSeconds + expiresAt13Replay with X-Step-Up-Token header14ValidateAsync (tenant + subject + purpose + revocation version)15Window GET / one-shot GETDEL16Validation passed17Continue into business handler18

Reading the diagram (verified against src/Hosts/BitzOrcas.Api/StepUp/StepUpEnforcementMiddleware.cs in the BitzOrcasVNext repository):

  1. Purpose resolution is a single in-memory lookup (step 2). A RequireStepUpAttribute declaration 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.
  2. Policy evaluation can exempt (step 3). Feature toggle off, purpose not enforced, machine callers, and AuditOnly observation all pass here; observation additionally writes a StepUpAuditOnlyObserved security audit — the data source for gradual rollout.
  3. 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.
  4. 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 outright
Feature 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 exemption

Policy 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):

FactorWire nameVerification kernelApplicability
TOTP authenticatortotpMFA connector TOTP validationUsers with a bound authenticator; default baseline factor
Passkeyfido2WebAuthn assertion (W3C Web Authentication Level 3)Planned: contract placeholder; admin UI and dialog do not render it yet
Email OTPemailOtpEmail OTP serviceFallback for users without MFA; masked target l***@example.com
SMS OTPsmsOtpSMS OTP serviceSame constraints; SIM-swap risk, transitional only
PasswordpasswordPassword hash re-verificationAccounts with passwords; lowest interaction cost
Recovery coderecoveryCodeAtomic 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):

  1. She clicks confirm; the frontend receives a guidance 403 and the dialog lists “recovery code” among the factors;
  2. She types one code from her password manager — single-line input, no resend button, case-insensitivity hinted;
  3. The server atomically consumes that code (TryConsume; at most one success under concurrency) and issues the credential;
  4. The frontend toasts “recovery code consumed” and the request replays successfully;
  5. 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 audit reason=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

PageTypeContent
QuickstartTutorialThree paths to wire an endpoint from zero, with expected failures
Contract referenceReferenceEndpoints, request/response JSON, 403 fields, full error table, configuration keys
Frontend integrationGuideInterceptor replay guard, dialog state machine, SDK usage
Rollout and operationsGuideAuditOnly-first rollout, threshold tuning, runbook, troubleshooting

Related topics: Two-factor authentication · Authorization · Impersonation

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%