⚠️ No Permissions-Policy — the native WebAuthn API would proceed normally.
← All demos · Passkeys · Permissions Policy

1Password Bridge Spoof — same-origin script

1Password's main-world ⇄ isolated-world bridge has no per-page authentication. Page JavaScript forges bridge messages and races a synthetic response, so a forged credential is returned and registered — even when Permissions-Policy denies WebAuthn entirely.

Architecture

Pre-flight check. Confirms 1P's wrappers are in place and the bridge is what we expect.

(click Pre-flight)

Vector 1 — spoof create-credential through the bridge

Forges a SYN/SYN-ACK/DIRECT-REQUEST sequence to the content script with attacker-chosen publicKey options. Content script forwards to the SW → 1P save-prompt UI fires → if the user clicks Save, an attacker-influenced credential is registered to their vault. The private key stays in the native helper, but the registration happens without the page ever calling navigator.credentials.create().

Vector 2 — disable 1P interception via update-settings

Main-world stub's update-settings handler sets R = body.authenticatePasskeys. With R = false, p() returns false, and subsequent create()/get() calls fall through to the native authenticator. Chains with Pattern 4a/4b: disable 1P, then synthesize a credential entirely in JS, register against the user's account.

Vector 3 — race a forged create-credential-success response

Listens for in-flight DIRECT-REQUEST messages, captures the msgId, and immediately races a synthetic DIRECT-RESPONSE before the legitimate one arrives. If we win the race, the page's await navigator.credentials.create() resolves with our forged attestation. Requires landing the response message before 1P's matching pending synRequests/directRequests entry is consumed by the legitimate response.

The "End-to-end" button does the full attacker flow against the demo backend: it calls /passkeys/api/register/begin.php?demo=5 to get a server challenge, fires navigator.credentials.create() (race wins, page receives forged credential), then POSTs the forged credential to /passkeys/api/register/finish.php?demo=5. Open DevTools → Network and you'll see the POST carrying the attacker-controlled attestationObject + the server returning 200 OK.

Demo 5 has its own per-demo session under the __Host-report_uri_demo_passkeys_5 cookie — credentials registered here are isolated from /passkeys/1/ and other demos. The panel below polls /passkeys/api/credentials.php?demo=5 so you can see the credential appear in this session immediately after the POST returns 200.

Credentials registered in demo-5 session

Live — polls /passkeys/api/credentials.php?demo=5 every second.

  • Loading…

After the race resolves, paste this into DevTools to byte-compare the credential you received against what you'd expect from real 1P vs the forgery:
console.log(new TextDecoder().decode(window.__lastCred?.response?.clientDataJSON))
Empty challenge: "" = forgery won. Real challenge: "<b64url>" = 1P won.

Live trace

All bridge messages this page sends or receives. Includes 1P's traffic when 1P doesn't stopImmediatePropagation (i.e. when the message is for a route 1P doesn't own).