Articles / Security

  • webauthn
  • passkeys
  • security
  • cryptography
  • phishing

What WebAuthn Actually Signs, and Why Phishing Can't Replay It

"Passkeys are phishing resistant" gets repeated a lot, usually without anyone saying what it means at the protocol level. The real answer is two fields buried inside every WebAuthn assertion: the origin and the challenge, both folded into the exact bytes the authenticator signs.

It isn't magic. It isn't really about passkeys being secrets you can't type into the wrong box either, though that's part of it. Once you've seen the structure, the phishing resistance stops looking like a marketing claim and starts looking like an unavoidable consequence of the maths.

Both ceremonies produce the same two objects

WebAuthn defines two operations, both driven by navigator.credentials:

  • Registration (create()) produces a new key pair inside the authenticator and hands the relying party (the "RP", meaning the website) the public key plus an attestation.
  • Authentication (get()) is the one that matters for phishing. The RP sends a challenge, the browser and authenticator build a small signed structure around it, and the RP checks that structure before letting anyone in.

Both ceremonies produce the same two objects, which is why they're worth looking at together:

  • clientDataJSON, built entirely by the browser;
  • authenticatorData, built entirely by the authenticator.

Neither is under the control of the web page's JavaScript, and that detail does all the actual work later on.

The browser, not the page, writes the origin

clientDataJSON is a plain JSON blob. It isn't signed on its own, but it's hashed into what does get signed. For an authentication ceremony it looks like this:

{
  "type": "webauthn.get",
  "challenge": "N3z9v0k5j2...base64url...",
  "origin": "https://example.com",
  "crossOrigin": false
}

The challenge is a random value the RP generated moments earlier and sent down with the authentication request.

The origin is not something the calling page supplies. The browser fills it in from the actual top-level origin of the document that invoked navigator.credentials.get(). A page can't lie about its own origin here any more than it can lie about the URL in the address bar, because the browser, not the page's script, writes this field.

What actually gets signed

The authenticator doesn't sign clientDataJSON directly. It hashes it with SHA-256, then signs the concatenation of its own authenticatorData and that hash:

signature = Sign(privateKey, authenticatorData || SHA-256(clientDataJSON))

authenticatorData is a compact binary structure:

  • a 32-byte hash of the relying party ID;
  • a flags byte;
  • a 4-byte signature counter;
  • only during registration, the attested credential data.

That first field, the RP ID hash, is the second half of the phishing defence. The credential is scoped to a specific RP ID, usually the registrable domain such as example.com. The authenticator will only produce a signature using that hash for requests it believes come from that RP ID.

Here's roughly what checking one of these looks like server-side, assuming an ES256 credential:

package webauthn

import (
	"crypto/ecdsa"
	"crypto/sha256"
	"encoding/binary"
	"encoding/json"
	"errors"
)

// clientData mirrors the fields WebAuthn defines in clientDataJSON.
type clientData struct {
	Type      string `json:"type"`
	Challenge string `json:"challenge"`
	Origin    string `json:"origin"`
}

// authData is the fixed-size prefix of authenticatorData for an
// authentication assertion (no attested credential data at this point).
type authData struct {
	RPIDHash  [32]byte
	Flags     byte
	SignCount uint32
}

func parseAuthData(b []byte) (authData, error) {
	if len(b) < 37 {
		return authData{}, errors.New("authenticatorData too short")
	}
	var a authData
	copy(a.RPIDHash[:], b[:32])
	a.Flags = b[32]
	a.SignCount = binary.BigEndian.Uint32(b[33:37])
	return a, nil
}

// verifyAssertion checks an ES256 WebAuthn assertion. It's deliberately
// stripped down: a real implementation also tracks used challenges,
// checks the user-presence flag, and handles other COSE algorithms.
func verifyAssertion(pub *ecdsa.PublicKey, rawAuthData, clientDataJSON, sig []byte, rpID, wantOrigin, wantChallenge string) error {
	var cd clientData
	if err := json.Unmarshal(clientDataJSON, &cd); err != nil {
		return err
	}
	if cd.Type != "webauthn.get" {
		return errors.New("wrong ceremony type")
	}
	if cd.Origin != wantOrigin {
		return errors.New("origin mismatch")
	}
	if cd.Challenge != wantChallenge {
		return errors.New("challenge mismatch or replay")
	}

	ad, err := parseAuthData(rawAuthData)
	if err != nil {
		return err
	}
	wantRPIDHash := sha256.Sum256([]byte(rpID))
	if ad.RPIDHash != wantRPIDHash {
		return errors.New("relying party ID mismatch")
	}

	clientDataHash := sha256.Sum256(clientDataJSON)
	signed := append(rawAuthData, clientDataHash[:]...)
	digest := sha256.Sum256(signed)
	if !ecdsa.VerifyASN1(pub, digest[:], sig) {
		return errors.New("signature verification failed")
	}
	return nil
}

Four checks, and dropping any one reopens the hole

  1. Ceremony type stops a registration assertion being replayed as an authentication one.
  2. Origin is the phishing defence.
  3. Challenge is the replay defence.
  4. RP ID hash is a second, independent phishing defence, baked into the signed bytes rather than the JSON.

The one most often dropped is the origin check, because it looks redundant with TLS. Drop it and the exact hole passkeys were built to close comes straight back.

This has happened in the wild. Some early WebAuthn library integrations verified the signature and the challenge but trusted whatever origin the client claimed rather than comparing it against a fixed value, which quietly defeats the whole point.

Why a phishing proxy can't just relay it

Compare how a reverse-proxy phishing kit, the Evilginx-style setup, defeats a password plus TOTP login. The kit sits between the victim and the real site and shows a convincing copy of the login page.

It then simply forwards whatever the victim types, including the six-digit code, straight through to the real site in real time. Nothing about a password or a TOTP code is bound to where it was entered, so the relay works perfectly.

Try the same trick against WebAuthn and it falls apart at the first step. The victim's browser is talking to evil-example.com, so that's the origin it writes into clientDataJSON, and that's the RP ID it asks the authenticator to use. Then one of two things happens:

  • the authenticator has no credential scoped to evil-example.com at all, so there's nothing to sign; or
  • the attacker registered their own credential there earlier, and the authenticator happily signs something, but that signature is only ever valid for evil-example.com.

Forwarding it to the real example.com produces an origin mismatch and a rejected login. There's no point in the flow where the attacker gets to choose the origin in the signed data, because that field never passes through anything the attacker controls.

The signature counter mostly doesn't work for passkeys

The 4-byte counter in authenticatorData exists to catch cloned hardware authenticators. It should increase on every use. If an RP sees it go backwards or repeat, that's a strong signal the same private key material exists in two places. It works well for physical security keys.

For actual passkeys it's largely theatre. A passkey synced through iCloud Keychain or a similar password manager is deliberately the same credential on every device it's synced to. There's no single piece of authenticator state to keep a counter in, so most platform implementations just report zero every time.

The WebAuthn spec anticipates this: an RP should treat a zero counter as "not supported" rather than flag it as impossible clone activity. Still, the classic clone-detection story quietly stops applying once a credential is a synced passkey rather than a hardware token.

That's not really a flaw; it's a trade-off that comes with the convenience of syncing. But it's worth knowing if a sign-count check is doing any load-bearing work in an anomaly detection setup.

Small side-path: the field that closes off invisible iframes

One more field in clientDataJSON earns a mention. crossOrigin records whether the calling context was itself embedded in a cross-origin iframe.

WebAuthn calls from a cross-origin iframe are blocked by default unless the top frame explicitly opts in via a permissions policy. That closes off a whole category of "invisible iframe on a phishing page loads the real login widget" attacks that used to work against embedded password forms. Small field, easy to miss, doing real work.

Back to the main point. None of this requires the user to notice a suspicious domain, spot a missing padlock, or read a URL carefully before their first coffee, which is precisely where password and OTP phishing succeeds in practice. The browser does the origin check on the user's behalf, every single time, without asking them to.