Phishing-Resistant Auth · Chapter 5 of 5
Mixing OTP and passkeys: a pragmatic step-up ladder
Assign each action an assurance level, map factors to levels honestly, and step users up only when the action needs it.
Most products cannot switch every user to passkeys on a given Tuesday, and should not try. The practical goal is narrower: make sure the actions an attacker most wants can only be taken with a phishing-resistant factor, while everything else stays as smooth as it is today. A step-up ladder is the tool for that. It assigns every sensitive action a required assurance level, and asks for a stronger factor only when the current session does not already meet it.
Define levels by what an attacker gains
Start from actions, not factors. List what a signed-in user can do and ask what an attacker would gain from each. Reading a dashboard leaks some data; changing the email address hands over the account; adding a payout destination steals money. Group actions into three or four levels. Fewer levels are easier to reason about and to test, and the boundaries become obvious once you sort by attacker gain.
- L0 — browse: read-only, low-sensitivity pages. Any session.
- L1 — personal data: view statements, edit display preferences. Password plus OTP, or a passkey.
- L2 — account control: change email, phone, password; add or remove credentials. Phishing-resistant factor within the last 10 minutes.
- L3 — money movement: new payout destination, large transfer. Phishing-resistant factor within the last 5 minutes, plus a notification and a cooling-off period for new destinations.
Map factors honestly
Each factor gets the highest level it can defend against your threat model. A password alone defends L0. Password plus OTP defends against credential stuffing and is reasonable for L1, but chapter 1 showed it falls to real-time relay, so it must not satisfy L2. A passkey with user verification is phishing-resistant and can satisfy any level, provided it is recent enough. Push approvals sit with OTP. Recovery codes satisfy nothing beyond registering a new credential under a hold.
Be honest about users who have no passkey yet. If L2 requires one, a user without a passkey cannot change their email address. That can be correct — offer to create a passkey right there, authenticated by their existing factors, then hold the sensitive change for a delay. The delay is what keeps a relayed OTP from being upgraded into immediate account control, because the real owner gets time to notice and cancel.
Session assurance as data
Represent assurance as fields on the session: the highest level achieved, the factor used, and when. Every protected endpoint declares its required level and maximum age. A middleware compares the two and, if the session falls short, returns a structured response that tells the client to start a step-up ceremony for that level. After a successful step-up, update the session fields; do not issue a new session from scratch, or you lose the audit trail linking the step-up to the original sign-in.
type Assurance = { level: 0 | 1 | 2 | 3; factor: "password" | "otp" | "passkey"; at: number };
function requireLevel(need: 1 | 2 | 3, maxAgeSec: number) {
return (s: { assurance: Assurance }) => {
const fresh = Date.now() - s.assurance.at <= maxAgeSec * 1000;
const ok = s.assurance.level >= need && fresh && (need < 2 || s.assurance.factor === "passkey");
if (!ok) throw new StepUpRequired({ level: need, methods: need >= 2 ? ["passkey"] : ["passkey", "otp"] });
};
}Bind step-up to the action, not just to the session, for L3. When a user confirms a new payout destination, include a hash of the destination in the WebAuthn challenge, or record the step-up against a pending action ID. Then a step-up performed for one transfer cannot be reused by injected script to authorise another.
A day on the ladder
An illustrative example from our fictional Larkspur Ledger. Tobias Ndlovu signs in at 2026-10-13 08:55 UTC with password and TOTP; his session is L1. He views statements freely. At 09:20 he adds a new payout account; the server responds with a step-up requirement for L3. His browser offers his phone passkey, he unlocks it with a fingerprint, and the session becomes L3 with factor passkey at 09:20:41. The new destination enters a 24-hour hold and he receives an email. At 09:40 he tries to change his phone number; his L3 step-up is now twenty minutes old, beyond the ten-minute limit for L2, so he confirms with the passkey again.
Notice what an attacker who phished Tobias’s password and TOTP at 08:55 could have done: read statements. Every action that leads to money or control required a passkey, which the proxy could not relay, or a delay the real owner would see. That is the whole design goal achieved without forcing any user to change how they sign in.
Rolling it out
Ship in this order. Instrument sessions with assurance fields and log which level each endpoint would require, without enforcing. Prompt passkey creation after sign-in until a healthy share of active users have one. Enforce L3 first, because it protects money and affects the fewest requests. Then enforce L2. Leave L1 accepting OTP indefinitely, because the marginal security of forcing passkeys for reading statements is small and the support cost is not.
Write the ladder down in a short document your whole team can read, and review it whenever a new sensitive action ships. The most common failure is not a bad decision but a new endpoint that nobody assigned a level to.
Each level should also have an owner who approves exceptions, so the ladder does not erode one special case at a time.
// bench check
0/3 correct · 0 answered
1. Why must OTP not satisfy L2 (account control)?
2. A user without a passkey wants to change their email (L2). A reasonable flow is…
3. What does binding a step-up to a pending action prevent?