Risk-Based Step-Up & Account Recovery · Chapter 2 of 5
Policy design: when to ask for more
Combine action sensitivity with risk facts in a small, testable matrix. Decide what “more” means, how long it lasts, and how to keep policy reviewable.
Signals tell you how unusual a request looks. Policy decides what to do about it. The temptation is to build a single numeric score and set thresholds on it, but scores hide reasoning and drift silently as features are added. Marisol takes a different route at Tidewater Mutual: a short, explicit matrix that crosses the sensitivity of the action with a risk band derived from facts, where every cell names a response a human can audit.
Start from actions, not signals
The first column of the matrix is the list of things a member can do, grouped by what an attacker gains. Tidewater uses four tiers. Read actions — view balance, view statements — leak information but move no money. Low-value write actions — change a nickname, set alerts — are reversible. Value-moving actions — transfer to an existing payee, pay a bill — move money along known paths. Control actions — add a new payee, change phone or email, register a new device, raise a transfer limit — change who can act on the account in future. Control actions are the ones that turn a single compromise into a persistent one, so they sit at the top.
Grouping by consequence rather than by screen matters because the same screen can host actions in different tiers. The profile page shows the member’s address (read) and lets them change the phone number (control). If policy is attached to pages, the phone change inherits the weak protection of the page. Attaching policy to actions forces every endpoint to declare its tier, and an endpoint without a tier fails closed in code review.
Risk bands from facts
The second axis is a coarse risk band computed from the facts of the previous chapter. Tidewater uses three bands with written definitions. Familiar: known device seen within 90 days, no network red flags. Unfamiliar: unknown device, or known device combined with a country change or a hosting network. Hostile: any fact strongly associated with attack, such as the address producing failures against many accounts in the last hour, or a session whose device key does not match the one that logged in.
function band(f: Facts): "familiar" | "unfamiliar" | "hostile" {
if (f.network.recentFailures === "high") return "hostile";
if (!f.device.known || f.network.hosting || f.network.countryChanged) return "unfamiliar";
return "familiar";
}What does “more” mean?
A step-up is a request for additional evidence, and not all evidence is equal. Ranked from weakest to strongest at Tidewater: re-entering the password; an SMS or email one-time code; a TOTP from an enrolled app; an in-app approval on an enrolled device showing the action details; a passkey assertion. The ranking follows the earlier series. OTP proves possession of something but can be relayed in real time; a passkey cannot be relayed to a different origin. For control actions in the unfamiliar band, the matrix demands a passkey or in-app approval, because those are the ones a phishing proxy cannot complete.
The step-up should also be bound to the action. An in-app approval that says “Approve sign-in?” is weaker than one that says “Add payee Quinn Halvorsen, account ending 4471?”. Binding the prompt to the specific transaction is what turns a second factor into a transaction confirmation, and it is what defeats an attacker who has a live session but needs the member to approve something the member did not intend.
How long does it last?
Every successful step-up creates a short-lived elevation. Policy must say how long and for what. Tidewater elevates a session for ten minutes for value-moving actions and binds control-action approvals to the single action that requested them. Without a time limit, one step-up on Monday covers a payee change on Friday from the same browser tab, which defeats the point. Without action binding, a step-up for a small transfer silently authorises a phone-number change in the same window.
Keep policy testable
Because the matrix is small, it can be tested exhaustively. Tidewater’s policy module has a table-driven test that enumerates every tier and band and asserts the expected response, plus regression cases built from real incident timelines. Changing a cell requires changing a test, which forces a reviewer to see the change explicitly. That is the opposite of a machine-learned score, where a retrain can quietly move thousands of decisions.
Scores still have a place: a model can produce one of the facts, for example behaviour.anomalous = true when a session’s navigation looks scripted. The model is then one input among many, its output is a named fact with a documented meaning, and the policy that acts on it remains readable. If the model misbehaves, the fact can be disabled without rewriting policy.
| Threat | Inherent | Mitigation | Residual |
|---|---|---|---|
| Real-time phishing proxy adds a new payee | Critical | Control tier always requires passkey or bound in-app approval when unfamiliar | Medium |
| Stolen session cookie reused from attacker device | High | Device-key mismatch places session in hostile band; value actions blocked | Low |
| Step-up reused for a different action | High | Control approvals bound to the single action; 10-minute window for value tier | Low |
Edit the policy
The matrix above, as a rules file. Change a decision and the sample sessions — Dorian, Yusra, Bram and two hostile sources — re-evaluate immediately and show which rule fired. Try deleting the first rule and see what the credential-stuffing source gets.
if (action.sensitive) {await requireOtp(user); // every sensitive action, every time}
// bench check
0/3 correct · 0 answered
1. Why attach policy to actions rather than pages?
2. Which step-up best defends a control action against a real-time phishing proxy?
3. What is the risk of a step-up with no expiry or action binding?