What must a server verify when it receives a passkey assertion?

17.12.1 · L8 · Interview

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… →