Craft shipped passkeys, then shipped the one WebAuthn mistake the spec exists to prevent
Passkeys are supposed to be the authentication method that's structurally resistant to replay — no shared secret to steal, no password to phish, a challenge-response that's only valid once. So when I read GHSA-wg23-69c2-gjc8 (CWE-294, Authentication Bypass by Capture-replay, Moderate, published 25 July), I wanted to understand exactly how Craft's implementation lost that property, because the root cause is the single most common WebAuthn implementation mistake — and seeing it in a real, shipped CMS is a better teaching example than most spec documentation.
Two flaws, compounding
The advisory describes a bug with two parts, and either one alone would be bad; together they're a full replay. First: Craft's users/login-with-passkey endpoint accepts the PublicKeyCredentialRequestOptions — the challenge — from the unauthenticated request body itself, rather than from a value the server issued and bound to a single login attempt. That means an old challenge can simply be resubmitted. Second, and this is the part that actually explains why resubmitting an old challenge works: after a WebAuthn assertion validates successfully via web-auth/webauthn-lib, Craft never persists the updated PublicKeyCredentialSource the library returns — the one carrying the bumped signature counter. The stored counter stays stale indefinitely, so the same captured assertion keeps passing the counter check every time it's replayed.
What a signature counter is actually for
This is the part worth slowing down on if you haven't implemented WebAuthn yourself. Every time an authenticator signs an assertion, it increments a counter and includes the new value in the signed response. The server's entire job on each login is to check that the counter it just received is strictly greater than the counter it has stored — and if it is, save the new value. That's the whole mechanism: a monotonically increasing number that only the authenticator can produce, verified against the last value you saw. If a captured assertion ever gets replayed, the counter it carries will match what's already stored (since nothing new happened), and the check fails. The entire replay-resistance of WebAuthn, for a given credential, lives in that one comparison-and-save step. Skip the save, and the comparison becomes pointless — you're forever checking a new value against the same stale baseline, and it'll always look valid, because an unchanged counter can't be told apart from a correctly-incrementing one you simply never recorded.
Who's actually affected
Craft 5.0.0-RC1 through before 5.10.5, patched in 5.10.5. The advisory notes this affects fresh installations with passkeys enabled, not some edge-case upgrade path. The practical attack: anyone who captures a single successful login-with-passkey request body — through a debugging proxy, application logs, a compromised browser extension, anywhere that body might transit or get logged — can replay it to mint a new authenticated session for that user, indefinitely, until the credential is revoked. A mechanism specifically designed to be phishing- and replay-resistant became, for this bug's lifetime, a reusable bearer token if its request body was ever exposed once.
The honest framing
Drupal core still has no native passkey login — it's contrib-module territory, and I've generally treated that as Drupal being slow rather than cautious. This advisory is a useful correction to that instinct: Craft moved first and hit exactly the pitfall the WebAuthn spec's counter mechanism exists to close. Being first isn't free, and the fact that an experienced team can still ship this exact mistake is the actual argument for taking the spec's "verify and persist the counter, every single time" instruction as load-bearing rather than a formality — not evidence that passkeys are overhyped, just evidence that the two-line save-after-verify step is the part of the implementation that's easiest to get right in testing and easiest to silently drop under real conditions.