Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Security

Published:

Tags:
  • webauthn
  • passkeys
  • security
  • cryptography
  • phishing

Passkeys Under the Hood: What WebAuthn Actually Signs, and Why Phishing Can't Replay It

"Passkeys are phishing resistant" gets repeated a lot, usually without anyone explaining what that actually means at the protocol level. It isn't magic, and it isn't really about passkeys being secrets you can't type into the wrong box either, though that's part of it. The real answer is in two fields buried inside every WebAuthn assertion: the origin and the challenge, both folded into the exact bytes the authenticator signs. 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.

Two ceremonies, one shared structure

WebAuthn defines two operations, both driven by navigator.credentials: registration (create()) and authentication (get()). Registration 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 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 what makes them worth looking at together: clientDataJSON, built entirely by the browser, and 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.

What clientDataJSON contains

This is a plain JSON blob, not signed on its own but 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 cannot 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 does not 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, and, 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, and 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 all four matter: the ceremony type stops a registration assertion being replayed as an authentication one, the origin check is the phishing defence, the challenge check is the replay defence, and the RP ID hash check is a second, independent phishing defence baked into the signed bytes rather than the JSON. Drop any one of them, most commonly the origin check because it looks redundant with TLS, 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 this with 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, shows the victim a convincing copy of the login page, and 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 the browser asks the authenticator to use. The authenticator either has no credential scoped to evil-example.com at all, in which case there is nothing to sign, or, if the attacker registered their own credential there earlier, it happily signs something, but that signature is only ever valid for evil-example.com. Forwarding it on to the real example.com produces an origin mismatch and a rejected login. There is no point in the flow where the attacker gets to choose what origin ends up in the signed data, because that field never passes through anything the attacker controls.

The signature counter that mostly isn't

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

It's largely theatre for actual passkeys, though. A passkey synced through iCloud Keychain or a similar password manager is deliberately the same credential on every device it's synced to, so there's no single piece of authenticator state to keep a counter in, and most platform implementations just report zero every time. The WebAuthn spec anticipates this and says an RP should treat a zero counter as "not supported" rather than flag it as impossible clone activity, but it does mean 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.

The field that closes off invisible iframes

The other field in clientDataJSON worth 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, which 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. It's a small field and easy to miss, but it's doing real work.

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.