Make a route inventory, define which actions need MFA at sign-in and which need a recent step-up, then verify the server rejects each action when the required assurance is absent or stale. Check the direct API, alternate methods and recovery paths, not just the button that starts the flow. The answer changes with your identity provider and session design: an application can validate signed assurance directly or bind a server-side record to a provider-validated challenge, but a browser variable or unchecked mfa=true field is not evidence. MFA also does not replace the current role and object checks needed for the action. OWASP's MFA guidance identifies sensitive actions and factor changes as cases requiring particular care; its authorization guidance still requires permission checks on every request.
Use this checklist in an authorized test environment with synthetic accounts. It is a route review, not proof that every unseen client or future route is covered.
Illustrative case: the payout setting behind a harmless screen
Illustrative case: Harbor Subscriptions requires MFA before its owner changes payout bank details. The browser asks for a second factor and then calls PATCH /api/accounts/{id}/payouts. The same operation is reachable from a mobile API client; a separate endpoint replaces an MFA factor. The page looks correct. A developer notices that the payout handler checks session.authenticated and the owner role, while an earlier UI loader checks session.mfaComplete. The mobile client does not use that loader.
That source trace supports a possible MFA bypass. It does not establish that the payout operation succeeds: the API gateway or service might apply the missing check. The review must find the final server decision before reporting a confirmed issue. A second, more subtle failure would be accepting a years-old “MFA completed” session property for an action whose policy requires a recent challenge.
Before examining code, write Harbor's illustrative policy: “Every human needs completed MFA to enter an account. A payout change or factor replacement additionally needs a provider-validated challenge within the last 10 minutes. This is Harbor's chosen window, not a universal standard. The actor must also hold the current owner capability for that account.” NIST's current SP 800-63B distinguishes authentication assurance, reauthentication and session continuation; a token refresh is not automatically a new authentication ceremony.
Fill a route and assurance matrix
Generate the initial list from the deployed router, gateway configuration, GraphQL mutations, mobile routes and job/API entry points. Then ask product and security owners which operations change credentials, access, money, exports or sensitive settings. A menu is not a reliable route inventory.
| Harbor action and entry | Required actor and assurance | Server evidence to find | Negative check with synthetic data |
|---|---|---|---|
GET /api/accounts/A/invoices |
Active A member; completed sign-in MFA | Validated session and current A membership | Restricted enrollment session is denied |
PATCH /api/accounts/A/payouts |
Current A owner; recent step-up | Validated challenge time/method bound to this session and subject | Owner session with stale challenge is denied |
| Mobile payout mutation | Same payout policy, despite different client | Shared enforcement or equivalent server decision | Mobile request without step-up is denied |
| Factor replacement | Current account member; recent qualifying step-up plus recovery policy | Existing-factor or approved recovery proof and audit event | Password-only recovery token cannot replace a factor |
| CI payout request | No human payout permission through a machine token | Credential purpose/action allowlist | Repository automation token is denied |
The matrix's value is the denial condition. “The UI prompts for MFA” does not prove the API denies a request that skips the UI. If your app offers a support role, SSO-only login, passkeys or alternate factor recovery, add rows for them rather than assuming they share the same code path.
Compare four sessions at the same sensitive route
At 10:05, Harbor's payout mutation requires a qualifying challenge within its chosen ten-minute window. The following records are synthetic; none was used to call an API:
S1: owner Dana, password only, no qualifying MFA event
S2: owner Dana, token issued 10:04, qualifying MFA event 08:00
S3: owner Dana, qualifying MFA event 10:01, event bound to S3
S4: owner Dana, claims event from another session S9
Expected decisions are deny S1, require fresh step-up for S2, permit S3 only if current account/action permission and request integrity also hold, and reject S4's mismatched event. S2 is the easy implementation trap: the token is one minute old, but the authentication event is two hours five minutes old.
A schematic decision makes the dependencies visible:
require validated_human_session(request)
require current_owner(subject, account)
event = trusted_assurance_for_this_session(session)
require event.subject == subject and event.session == session.id
require event.qualifies_for("payout.change", current_policy)
require 0 <= now - event.authenticated_at <= 10 minutes
perform protected payout mutation under the action's authorization rules
This is explanatory pseudocode, not a complete protocol implementation. A future timestamp should not pass because a negative age is “less than ten minutes”; real systems must define clock tolerance and reject invalid evidence. The final mutation also needs the application's concurrency and current-permission rules. A challenge check performed before an unrelated long-running process is not automatically fresh at the eventual sensitive action.
OIDC's authentication-time definition distinguishes auth_time from iat, and its refresh rules preserve the original authentication time when that claim is returned. The login or step-up transaction must also bind its response to the initiating browser/session and reject replay; copying an event reference cannot establish that binding. An identity authentication event and an approval of exact payout details are different: Harbor needs a separate transaction-confirmation design if its policy requires the latter.
A provider's generic mfa value still requires a qualified interpretation; the application cannot infer hardware use or action binding from a familiar string.
Follow the same decision through the alternate client
The browser loader may request step-up before displaying the payout form. The mobile client may call the mutation directly. Both must reach the server decision above. Put the policy in a shared enforcement path or equivalent mandatory controls, then inspect route registration to establish that both use it.
The review output should bind each request to the target-state result:
| Synthetic case | Expected response class | Expected payout record |
|---|---|---|
| Browser S2 skips loader and calls API | Step-up required/deny | Unchanged |
| Mobile S1 calls equivalent mutation | Deny | Unchanged |
| S3 has recent challenge but lost owner role | Deny | Unchanged |
| S3 has current owner role and valid request | Allowed under policy | Intended change only |
| S4 copies another session's assurance reference | Deny | Unchanged |
A 403 after updating the bank record fails the policy. A universal 401 because the fixture is broken is inconclusive; the permitted S3 path establishes that the route can work. Keep expected and observed results distinct in the real review record.
Factor replacement and password recovery need their own route rows because they can change the assurance mechanism itself. Cookie-authenticated mutations also need an appropriate CSRF defense; recent MFA does not establish that a particular browser request was intended.
If the gateway is claimed to enforce this policy, obtain the exact deployed rule and verify the mobile alias cannot bypass it. If trustworthy challenge time is unavailable, require a qualified step-up or deny the sensitive action. Do not synthesize authentication history from token refresh time.
Inspect the challenge-to-action handoff
Harbor's payout form can complete a real challenge and still hand the result to the wrong operation. Trace how the application associates the returned authentication evidence with Dana's current session and the sensitive action being resumed. A browser field saying “challenge succeeded” is not a substitute for the trusted event lookup shown above.
In the synthetic flow, Dana opens the payout form in two tabs. One tab completes step-up for session S3; the other has an older form with different payout details. Harbor's policy must decide whether recent authentication is sufficient for either action or whether exact transaction confirmation is also required. The ten-minute assurance rule alone answers only the authentication question. Do not claim it proves Dana approved the second tab's bank details.
Record that distinction in the route matrix. A column for qualifying authentication describes the trusted event and freshness window. A separate column, if the product requires it, describes confirmation of the intended transaction. This prevents a reviewer from treating the MFA ceremony as evidence for every later sensitive payload.
The same distinction matters after errors. If the payout service rejects a validation error, the application may allow a corrected request within the same authentication window according to policy. If the action requires a one-time confirmation bound to exact details, changing those details may require a new confirmation. The review must inspect the chosen contract instead of assuming every successful challenge is single-use or indefinitely reusable.
Find route aliases that escape the visible flow
The route inventory should follow business operations, not just URL names containing payout. A GraphQL mutation, an administrator bulk-edit endpoint, or an older mobile alias may reach the same service method. Those are potential review targets to inventory from the actual application, not claims that Harbor contains them. The important question is whether each eligible human entry reaches the required assurance and current-owner decision.
A shared service method can help make enforcement consistent, but inspect its callers and exceptions. An internal job may legitimately use a different machine-authority policy. That exception should be explicit and inaccessible through an ordinary human request. Otherwise a caller could select the internal path and avoid the human step-up requirement.
For each alias, keep a link to its mandatory enforcement point. When a new route is added, the developer then has a concrete integration rule: the action must traverse the named policy decision before mutation. The reviewer can check the registration and service call rather than relying on a frontend prompt copied from another page.
Use failure results to improve the policy, too
Suppose an authorized staging comparison shows that every mobile owner is denied because the application cannot interpret the provider's assurance evidence. That is a usability and integration problem, not a reason to accept unchecked claims. The identity owner needs to qualify the provider mapping or provide a supported step-up route. Until then, the sensitive mutation should remain unavailable through that path under Harbor's stated policy.
Suppose instead that the recent challenge is accepted but the owner grant was removed before the mutation. The repair belongs in current authorization or its ordering, not in the challenge timer. Keeping these causes separate avoids strengthening MFA while leaving the actual permission gap unchanged.
The final handoff should name the failing route, the missing or stale evidence, and the unchanged sensitive record expected on denial. Pair it with a legitimate allowed case. This produces a useful developer task without suggesting that one successful prompt demonstrates complete MFA coverage.
For the next sprint, choose the five highest-impact mutations, fill the matrix and give each row an owner for its missing denial evidence. Then examine invitation privilege changes and session revocation after access removal as separate routes through the same boundary.