Manav.id
Standards ยท 16 min read

Passkeys prove you logged in. Who signed the wire?

Passkeys prove you logged in. Who signed the wire?

Passkeys are the best thing to happen to authentication in twenty years. They also created a blind spot that most security teams have not measured, because a passkey proves how a session started and says nothing at all about what happened inside it. This is a walk through exactly what a WebAuthn assertion signs, why a re-authentication prompt does not confirm a transaction, and how to close the gap using the deployment you already have.

The victory lap, and the thing nobody checked

Start by giving passkeys their due, because the critique only lands if the praise is honest.

For twenty years the industry fought credential phishing and lost. We tried password complexity rules, which produced predictable passwords. We tried SMS one-time codes, which turned out to be readable by anyone who could persuade a phone carrier to move a number. We tried authenticator apps, which moved the code out of the SMS channel but still asked a human to read six digits off one screen and type them into another, which meant a convincing fake screen collected them just as well. Every layer added friction for the user and only a small tax on the attacker.

WebAuthn broke that pattern with one design decision: the signature is bound to the origin. A passkey created for bank.example will not produce an assertion for bank-example-login.com, no matter how convincing the page, because the browser refuses to talk to the wrong key and the key would sign the wrong origin anyway. There is no shared secret to steal, no code to read out, no human judgement in the loop about whether a URL looks right. The attack class did not get harder. It stopped working. That is rare and it deserved the celebration it got.

Now the part that did not make the launch posts. Picture a treasury analyst at 9:15 on a Wednesday. She authenticates to the payments console with Face ID, which is to say with a passkey, and the origin binding does exactly what it promises. Then she works for four hours: opening batches, reviewing invoices, approving a payment run, adding a beneficiary, exporting a reconciliation file. At 13:40 she goes to lunch with the tab still open.

Every single one of those actions after 09:15 was authorized by a cookie. Not by a passkey, not by a key, not by anything a device physically holds. By a bearer token in a browser, which is to say by a string of characters that grants its holder the analyst's authority for as long as it remains valid, and which does not care who is holding it.

Short answer. No, passkeys do not stop session hijacking. WebAuthn makes the login unphishable by binding the assertion to the origin, but once a session is issued every subsequent action rides a bearer cookie that any thief of that session can use. Stopping post-login theft requires a separate signature bound to each high-value action, over the action's own payload, shown to the human on a device the attacker does not control.

What does a WebAuthn assertion actually sign?

This is the technical heart, and it repays five slow minutes because almost every confusion downstream comes from skipping it.

When an authenticator produces an assertion, it signs the concatenation of two things: authenticatorData followed by the SHA-256 hash of clientDataJSON. The interesting one is clientDataJSON, a small object the browser builds and hands to the authenticator. It looks like this:

{
  "type":      "webauthn.get",
  "challenge": "k7Hb2QeYcT1n0xR5aFj9Lw",
  "origin":    "https://bank.example",
  "crossOrigin": false
}

Three fields carry the security. type stops an assertion being replayed as a registration. origin is the anti-phishing property everyone praises: the browser fills it in, the page cannot lie about it, and the server rejects anything that does not match. And challenge is a random value the server generated a moment ago and expects to see returned exactly once, which stops replay of an old assertion.

Read that list again and notice what is not there. There is no amount. No beneficiary. No account number. No description of any action whatsoever. The assertion is a statement with a very specific and very narrow meaning:

"At this moment, on this origin, a user in possession of this credential completed a user-verification gesture in response to a challenge you issued."

That is a complete and useful statement. It is also, precisely, a statement about a moment in time and not about a transaction. The protocol never claimed otherwise. We built an ecosystem on top of it that quietly assumed otherwise.

The hotel key card analogy, and where it breaks

A passkey login is like the front desk checking your passport and issuing a key card. The passport check is excellent, genuinely hard to fake, and specific to you. The key card that comes out of it is a different kind of object entirely: it opens the door for anyone holding it, for as long as it is valid, and the door has no idea whose hand is on it.

Session cookies are key cards. That is not a flaw in the design, it is the design, and it is why the web scales. The flaw is in what we then put behind those doors. Checking a passport very carefully at the front desk is a poor control for a room that contains the wire room.

The analogy has a limit worth naming. A key card at least has to be physically carried. A session cookie can be copied a thousand times in a millisecond by an adversary-in-the-middle proxy, by an infostealer scraping the browser profile, or by a browser extension with the wrong permissions. Sessions are worse than key cards, and that is the whole business model of a category of criminal tooling.

Does re-authenticating with a passkey confirm a transaction?

This is the objection every engineer raises at this point, and it deserves a careful answer, because the honest answer is "almost, and the gap is exactly where the money is."

Many relying parties do prompt again before something sensitive: "confirm with your passkey to continue." That is genuinely better than nothing. It proves the credential is still present and that somebody performed a user-verification gesture recently, which defeats an attacker who stole a session and is operating it from elsewhere without the device.

What it does not do is bind that gesture to the action. The server issues a fresh random challenge, the authenticator signs it, and the resulting assertion means "somebody touched the key again." It does not mean "somebody saw and approved a transfer of $42,150 to Northwind Fabrication." Those are different claims and only one of them is a control on the payment.

Here is where it gets interesting. A relying party can put a transaction hash into the challenge field. Nothing stops you constructing the challenge as the SHA-256 of the canonical action payload rather than as random bytes. The signature is then cryptographically bound to that exact payload, and a verifier can prove the binding later. This is real, it works, and more teams should do it.

But there is a catch that keeps it from being the whole answer. The authenticator displays nothing about the challenge. Your fingerprint sensor does not show you the amount. Your phone's Face ID sheet says something like "Use Face ID to sign in", not "approve $42,150 to account ending 8812". So the user confirms a payload they cannot see, which means the binding is real for the verifier and invisible to the human. If the compromised layer is the page that constructed the payload, the human has no independent way to notice.

WebAuthn's early drafts anticipated exactly this. There were extensions along the lines of txAuthSimple and txAuthGeneric, intended to pass a human-readable string to the authenticator for display before signing. They did not survive into broad deployment, and the practical reason is not mysterious: almost no authenticator on the market has a screen capable of rendering arbitrary text in a trustworthy way. A fingerprint reader has no display at all. A phone has an excellent display, but it belongs to the platform rather than to the authenticator, and platform vendors have not exposed it for this purpose.

So we ended up with a standard that can bind a transaction and a device population that cannot show one.

Which existing controls actually bind the human to the action?

It is worth laying the options side by side, because the differences are usually blurred in vendor material and in board reporting.

ControlWhat it cryptographically provesWhat it does not proveSurvives session theft?
Password plus one-time codeSomebody knew a secret and read a codeAnything about who, or about any actionNo
Passkey at loginThe credential for this origin was present and a user-verification gesture happened at session startAnything about actions taken later in the sessionNo, for later actions
Passkey re-authentication before a sensitive actionThe credential was present again, moments agoWhich action was approved, or what the human sawPartly, defeats a remote session thief without the device
Challenge built from the payload hashThe credential was present and the assertion is bound to this exact payloadThat the human saw the payload, since the authenticator shows nothingYes, for that action
PSD2 dynamic linkingThe authentication code is bound to a specific amount and payee, and the payer is made aware of bothNothing much, but it applies only to in-scope payments in EuropeYes, within its scope
Payload signature displayed on a separate enrolled deviceThis enrolled human saw these exact details and confirmed them at this timeThat the human was uncoerced or not deceivedYes

Look at the PSD2 row for a moment, because it is the one place where a regulator forced this issue and the result is instructive. Under the European Banking Authority's regulatory technical standards on strong customer authentication, remote payments require what the rules call dynamic linking: the authentication code must be specific to the amount and the payee, any change to either must invalidate the code, and the payer must be made aware of the amount and the payee. That is transaction confirmation, written as law.

It works. It is also the exception rather than the pattern. It covers European remote payment transactions and nothing else. It says nothing about a payroll bank-detail change, an administrative role grant, a data export, a crypto withdrawal address, or a vendor master-file update, all of which move value or open the door to moving value, and none of which is a "payment" in the sense the rules mean.

Why does this gap matter more every year?

Three trends are pushing in the same direction, and none of them shows any sign of reversing.

Attackers moved to the session because the login closed

Adversary-in-the-middle phishing kits do not try to steal your password, because there is nothing useful to steal. They proxy the real login, let you authenticate perfectly, and take the session cookie that comes out the other side. Tycoon 2FA, one such kit sold as a service, reached more than 96,000 victims across 330 domains before Microsoft, Europol, and industry partners disrupted it in March 2026 (Microsoft Security blog, 4 March 2026). Its entire value proposition was harvesting live sessions from users whose authentication worked exactly as designed.

Meanwhile the money continues to leave after login. Business email compromise accounted for $3.046 billion in reported US losses across 24,768 complaints in 2025, with 86 percent of it moving by wire or ACH (FBI IC3 2025 Internet Crime Report). Every one of those losses happened in a flow where authentication either succeeded or was never the disputed step. We scored the full report by control in the $20.9 billion re-read.

The biggest single loss on record happened inside authenticated sessions

On 21 February 2025, roughly $1.5 billion left Bybit in what remains the largest theft in the history of digital assets. The signers were authenticated. The signers were legitimate. Post-mortem analysis by security researchers including BlockSec described a compromise reaching the signing interface, so what the signers saw and what they actually approved diverged. Every authentication control performed correctly. The screen lied. We wrote that up separately in irreversible by design, so make sure a human signed it.

Agents now operate inside your sessions by design

This is the trend that turns a serious problem into a structural one. Agentic browsers and computer-use agents work by acting inside a logged-in browser context. That is not a bug, it is the entire product: you stay authenticated and the agent does the clicking.

Which means the assumption underneath session-based authorization, that whoever holds the session is the human who authenticated, is now false by design rather than by compromise. When an agent submits a form in your banking tab, every control in the stack sees your session and your authentication. The question "did a human authorize this" has no answer at all, because nothing in the flow was ever built to record one. We looked at the delegation side of this in how delegation tokens work.

How do you close the gap without ripping out WebAuthn?

The good news is that this composes. You do not replace passkeys, you add a second signature at a small number of endpoints, and the two do different jobs.

The pattern has four steps.

One, canonicalize the action. Before anything is signed, serialize the action deterministically so the same logical action always produces the same bytes. Sorted keys, fixed number formatting, explicit units, no optional whitespace. Determinism is not a nicety here, it is the whole basis of later verification.

# The exact bytes that get hashed and signed.
action = {
  "action":        "payment.release",
  "amount":        "42150.00",
  "currency":      "USD",
  "beneficiary":   "Northwind Fabrication LLC",
  "account_last4": "8812",
  "invoice":       "INV-2026-4471",
  "nonce":         "b41f0c7e9a2d",
  "expires_at":    "2026-09-03T14:27:07Z"
}
payload = canonical_json(action)          # sorted keys, no stray whitespace
digest  = sha256(payload)                 # 32 bytes, the thing we bind to

Two, show the human the action on a device the attacker does not control. This is the step the authenticator cannot do for you. The amount and the beneficiary are rendered on the approver's enrolled phone, which received the payload independently of the browser session that created it. If the page has been tampered with, the phone shows the real payload and the discrepancy is visible to a human before anything is signed.

Three, sign the digest, not a random number. The user-verification gesture on that device produces a signature over the digest. Now the assertion means something specific rather than something temporal.

# Server side, at the action endpoint, before executing anything.
receipt = manav.sign(action)              # renders on enrolled device, returns receipt

ok = verify(
  receipt.signature,                      # Ed25519 over the receipt body
  public_key = published_key(receipt.key_id),
  bound_to   = sha256(canonical_json(action))
)
if not ok or receipt.expired():
    raise Reject("no valid human authorization for this payload")

execute_payment(action)                   # only now does the money move
store(receipt)                            # evidence, verifiable years later

Four, keep the receipt and make sure it verifies without you. The output is an Ed25519-signed object bound to the payload digest and the approver's enrolled key. Anyone with the published verification key can check it offline: an auditor, a bank, an insurer, a court, a colleague in five years. No callback, no API availability requirement, no need to trust the vendor who issued it. That last property matters more than it first appears, because it means adopting the verification side costs nothing and depends on nobody.

The developer documentation covers the payload and receipt formats in detail at docs, and there is a working flow you can click through at the signing demo. We have filed drafts describing the receipt and delegation-chain formats with the IETF and the OpenID Foundation. Those are filed, not ratified, and we would rather say so than imply a standing they do not yet have.

Which actions deserve this, and which do not?

A control applied everywhere is a control nobody keeps. The engineering question is not "how do we sign everything" but "which small set of actions genuinely warrants a signature."

A useful test: would you want a durable, independently verifiable record that a specific human approved this exact thing? If yes, sign it. If the honest answer is "not really", leave it on the session.

For most organizations the list is shorter than expected, usually five to twelve actions:

Notice that only two of those are payments. This is why "we are PSD2 compliant" is not the same answer as "our high-value actions are authorized", and why organizations outside Europe should not conclude the problem does not apply to them.

What are the honest limits?

Four, and they are not small.

A compromised approver device defeats everything. If malware controls the phone that renders and signs, it can render one thing and sign another, which is exactly the failure that took $1.5 billion out of Bybit. Separating the display device from the session device narrows the attack surface considerably, because the attacker must now compromise two independent things instead of one. It does not eliminate it, and anyone who tells you otherwise is selling.

Approval fatigue is real and it is a security risk. A human asked to sign forty things a day stops reading the fourth one. This is not a user-education problem, it is a design constraint, and the mitigation is discipline about the action list above. If your signing volume per approver per day is in double digits, you have chosen your thresholds wrong.

A deceived human still signs. This control makes it impossible for an attacker to act as the approver. It does nothing about an attacker who convinces the real approver to approve something real. What changes is the shape of the attack: instead of an email into a compromised mailbox, the attacker must reach a specific person and get them to confirm a screen displaying the true amount and the true beneficiary, inside a short validity window. Narrower, but not closed.

Enrollment is the root of trust and deserves the paranoia. Every guarantee here reduces to the binding between a human and a device. If that binding can be created by whoever controls a mailbox, the whole structure inherits the mailbox's security. Enrollment belongs at onboarding, under separate controls, with recovery paths that re-establish continuity rather than starting over from a document upload. That last point is its own subject, and it is where a great deal of current attacker attention is going.

What should you do this week?

  1. Write down your high-value actions, using the test above. Not systems, actions. Most teams have never made this list and are surprised by how short and how specific it is.
  2. For each one, trace what authorizes it today. If the answer is "the session", write that down plainly. That sentence is the finding, and it is usually the first time anyone has stated it in those terms.
  3. Add a second metric to your identity reporting. Keep "percent of logins on phishing-resistant MFA", it is a real number. Add "percent of high-value actions with an action-bound signature". Expect the second to start at zero.
  4. Check whether your re-auth prompts bind anything. Look at the challenge construction in your own code. If it is random bytes, your prompt proves presence, not approval. Moving to a payload-derived challenge is often a small change and a genuine improvement even before you add a display.
  5. Audit your session lifetimes and your agent access together. Ask specifically which automated tools hold live sessions to systems on your action list, and what happens when one of them is instructed by a web page rather than a person.
  6. Test the receipt story with your auditor and your insurer. Ask what evidence they would accept that a specific human approved a specific payment. The answers tend to be revealing, and an offline-verifiable receipt is unusually easy for both to accept because neither has to trust you.
  7. Do not retire your transaction controls. The most expensive version of this mistake is a team that removes a payment confirmation step because "we moved to passkeys". That is exactly backwards.

Frequently asked questions

Do passkeys stop session hijacking? No. Passkeys make the login unphishable by binding the assertion to the origin, but once a session is issued, every action rides a bearer cookie that works for whoever holds it. Adversary-in-the-middle kits and infostealers target that cookie precisely because the login itself is no longer worth attacking. Stopping post-login theft needs a signature bound to each action.

What is transaction confirmation in WebAuthn? It is the idea that an authenticator should display the details of an action and sign those details, rather than signing a random challenge. Early WebAuthn drafts included extensions for it, but they did not reach broad deployment, largely because most authenticators have no trustworthy display. Today it is achieved by rendering and signing on a separate enrolled device.

Does re-authenticating with a passkey confirm a transaction? Only partially. A re-auth prompt proves the credential was present again moments ago, which defeats a remote session thief. It does not prove which action was approved, because the assertion signs a random challenge and the authenticator displays nothing. Binding the challenge to the payload hash fixes the cryptography, but the human still cannot see what they confirmed.

What is dynamic linking under PSD2? A requirement in the European Banking Authority's technical standards on strong customer authentication that the authentication code for a remote payment be specific to the amount and the payee, that any change invalidate it, and that the payer be made aware of both. It is transaction confirmation written as regulation, and it applies only to in-scope European payments.

Is a payload-bound challenge enough on its own? It is a real improvement and worth doing. It makes the assertion cryptographically specific to one action, so a stolen session cannot reuse it. What it does not give you is human visibility, since the authenticator shows nothing about the challenge. If the compromise is in the page that built the payload, the user has no way to notice.

How many actions should require a signature? Usually five to twelve. Payment release above a threshold, beneficiary and payee changes, bank-detail changes in payroll or vendor records, privileged access grants, bulk data export, disabling security controls, and delegation of authority to an agent. Signing more than that produces approval fatigue, which is its own risk.

Sources

  1. W3C, Web Authentication: An API for accessing Public Key Credentials, for authenticatorData, clientDataJSON, and extension history. w3.org/TR/webauthn-3
  2. European Banking Authority, regulatory technical standards on strong customer authentication and common and secure communication under PSD2, for dynamic linking. eba.europa.eu
  3. Microsoft Threat Intelligence, Inside Tycoon2FA: how a leading AiTM phishing kit operated at scale, 4 March 2026. microsoft.com
  4. FBI Internet Crime Complaint Center, 2025 Internet Crime Report, for business email compromise figures. ic3.gov
  5. BlockSec, incident analysis of the February 2025 Bybit compromise. blocksec.com/blog
  6. FIDO Alliance specifications and deployment guidance. fidoalliance.org
Ask your identity team what percentage of logins use passkeys and you will get a proud number. Ask what percentage of high-value actions carry a signature and you will get a pause.

This lesson is part of The standards gap, the guide to the whole problem area.