What must a server verify when it receives a passkey assertion?
Predict first
Which assertions are accepted?
import Foundation
import CryptoKit
// ---- the authenticator (a phone's Secure Enclave in real life) ----
let rpId = "bank.example"
let credential = P256.Signing.PrivateKey() // public half was stored at registration
func assertion(challenge: String, origin: String, flags: UInt8, counter: UInt32) -> (authData: Data, clientData: Data, signature: Data) {
var authData = Data(SHA256.hash(data: Data(rpId.utf8)))
authData.append(flags)
withUnsafeBytes(of: counter.bigEndian) { authData.append(contentsOf: $0) }
let clientData = Data(#"{"type":"webauthn.get","challenge":"\#(challenge)","origin":"\#(origin)"}"#.utf8)
let signed = authData + Data(SHA256.hash(data: clientData))
return (authData, clientData, try! credential.signature(for: signed).derRepresentation)
}
// ---- the relying party server ----
var storedCounter: UInt32 = 41
func verify(_ a: (authData: Data, clientData: Data, signature: Data), issuedChallenge: String) -> String {
let client = try! JSONSerialization.jsonObject(with: a.clientData) as! [String: String]
guard client["type"] == "webauthn.get" else { return "reject: wrong type" }
guard client["challenge"] == issuedChallenge else { return "reject: challenge is not the one we issued" }
guard client["origin"] == "https://bank.example" else { return "reject: wrong origin" }
guard a.authData.prefix(32) == Data(SHA256.hash(data: Data(rpId.utf8))) else { return "reject: rpIdHash mismatch" }
let flags = a.authData[32]
guard flags & 0x01 != 0 else { return "reject: user not present" }
guard flags & 0x04 != 0 else { return "reject: user not verified" }
let counter = a.authData.suffix(4).reduce(UInt32(0)) { $0 << 8 | UInt32($1) }
let signature = try! P256.Signing.ECDSASignature(derRepresentation: a.signature)
guard credential.publicKey.isValidSignature(signature, for: a.authData + Data(SHA256.hash(data: a.clientData))) else { return "reject: bad signature" }
guard counter == 0 || counter > storedCounter else { return "reject: counter did not increase (cloned?)" }
storedCounter = counter
return "accept (counter \(counter), synced passkey: \(flags & 0x10 != 0))"
}
print(verify(assertion(challenge: "c1", origin: "https://bank.example", flags: 0x1d, counter: 42), issuedChallenge: "c1"))
print(verify(assertion(challenge: "c1", origin: "https://bank.example", flags: 0x1d, counter: 42), issuedChallenge: "c2"))
print(verify(assertion(challenge: "c3", origin: "https://bank-example.evil", flags: 0x1d, counter: 43), issuedChallenge: "c3"))
print(verify(assertion(challenge: "c4", origin: "https://bank.example", flags: 0x01, counter: 44), issuedChallenge: "c4"))
print(verify(assertion(challenge: "c5", origin: "https://bank.example", flags: 0x05, counter: 40), issuedChallenge: "c5"))
var tampered = assertion(challenge: "c6", origin: "https://bank.example", flags: 0x05, counter: 45)
tampered.clientData = Data(String(data: tampered.clientData, encoding: .utf8)!.replacingOccurrences(of: "c6", with: "c6 ").utf8)
print(verify(tampered, issuedChallenge: "c6 "))
print(verify(assertion(challenge: "c7", origin: "https://bank.example", flags: 0x05, counter: 0), issuedChallenge: "c7"))
- The first and the counter-0 synced one; the rest are rejected for a specific reason
- All are accepted
- Only the tampered one is accepted
Mental model. A passkey login is a signed visitor slip: the guard checks the visitor, the building, the date written on it and that the stamp is new.
The authenticator returns authenticatorData (32-byte hash of the relying-party id, a flags byte, a 4-byte big-endian signature counter), clientDataJSON (type, the challenge, the origin) and a signature over authenticatorData ‖ SHA-256(clientDataJSON). The server checks, in order: type is webauthn.get; the challenge equals the one it issued for this session (single use); the origin is its own; the rpIdHash matches SHA-256("bank.example"); the flags show user present (0x01) and, if you require it, user verified (0x04); the signature verifies against the stored public key; and the counter increased. The run does all of this with a simulated authenticator: a wrong challenge, a look-alike origin, a missing user-verified flag, a lower counter and a tampered clientData are each rejected for the right reason, and a good assertion is accepted.
Two modern details: synced passkeys set the backup flags (0x08 eligible, 0x10 backed up; the accepted first assertion above reports synced) and often keep the counter at 0, so require monotonic counters only when they are non-zero. Phishing resistance comes from the origin and rpId binding done by the platform, which a server cannot add after the fact. The registration step (attestation and credential storage) and the platform UI (ASAuthorizationController) were not run; the verification logic here uses CryptoKit with a generated P-256 key.
Every check can independently reject an assertion.
Common traps
- Reusing a challenge. It must be random and one-time.
- Rejecting counter 0. Synced passkeys do not increment.
They might follow up with
- Why verify the origin? It is what defeats phishing sites.
Related questions
- 17.5.1 How do passkeys, Sign in with Apple and ASWebAuthenticationSession work in AuthenticationServices?
How do you validate a Sign in with Apple identity token on a server… →