September 3, 2026
How to Test Whether Passkeys Resist Iframe Phishing
Could a scam site hide a real login page inside an <iframe> — a page-inside-a-page — to trick a passkey login into working anyway? Rather…

By Roy Wong
7 min read
Could a scam site hide a real login page inside an <iframe> — a page-inside-a-page — to trick a passkey login into working anyway? Rather than guess, this tutorial tests it, step by step, using nothing but a browser and a local practice site.
Before you start._ Everything in this tutorial runs on a local practice site you set up yourself — no real account or third-party service involved anywhere._
If you're studying cyber security or digital development, this is a good worked example of how to actually test a security claim instead of taking it on faith: form a hypothesis, run a real test, read the evidence, and only then draw a conclusion.
01. What a passkey actually is
A password is a secret you type in. Anyone who tricks you into typing it into the wrong place now has it, and can use it anywhere.
A passkey works differently. When you register one, your device generates a matched pair of cryptographic keys:
- A private key — generated inside your device's secure hardware (Windows Hello, a phone's secure enclave, a USB security key) and never transmitted anywhere, not even to the site you're logging into.
- A public key — sent to the site once, at registration, and stored there against your account. Public keys aren't secret; they're only useful for checking a signature, not creating one.
Crucially, that key pair is cut for one specific address. The formal name for that address is the Relying Party ID, or rp.id — and every browser enforces, at the platform level, that a site can only ever request a signature scoped to its own domain. A scam site copying a real login page cannot ask for, or receive, a signature valid for the real address — the browser simply won't produce one on that site's behalf. That's the property this whole test is built around.
Field note — rp.id The field name differs by call. Registration (
navigator.credentials.create()) nests it:rp: { id: "example.com" }. Signing in (navigator.credentials.get()) flattens it:rpId: "example.com". Same concept, two shapes — a real asymmetry in the WebAuthn spec, not a typo.
02. Setup
Everything below runs on two local static file servers on different ports. Different ports count as different origins, which is what makes the comparisons in Test 2 and Test 3 meaningful — a same-origin iframe would sidestep the whole question.
Put these files in one folder:
webauthn-demo-register.html— registers a practice passkey (Test 1)webauthn-demo-frame-open.html+webauthn-demo-top-open.html— the unprotected pair (Test 2)webauthn-demo-frame.html+webauthn-demo-top.html— the protected pair, onceserve.jsonis added in Test 3
Then start both servers and leave them running for the rest of this tutorial:
# terminal 1
$ npx serve -l 8731 .
# terminal 2
$ npx serve -l 8732 .# terminal 1
$ npx serve -l 8731 .
# terminal 2
$ npx serve -l 8732 .Both http://localhost and http://127.0.0.1 are treated as secure contexts without HTTPS — the one deliberate exception browsers make for local testing, since there's no real network between browser and server to intercept. It doesn't extend to file:// pages, which have no domain at all for a passkey to bind to, or to a real LAN address like 192.168.x.x.
03. Test 1 — register a practice passkey
A page calling navigator.credentials.create(), run top-level (registration is always blocked inside a frame, with no override — that part of the spec has no exceptions).
Worth knowing before you try it: clicking "Register" a second time doesn't create a duplicate. Per the CTAP2.1 spec, an authenticator stores at most one resident credential per (rp.id, user.id) pair — registering again with the same pair overwrites the first rather than adding a second. That's confirmed, not assumed: a second click logs a different credential.id (a fresh key pair is generated either way), but only the newest one is ever retrievable afterward — verified directly with the "Check credential via get()" button below, which calls navigator.credentials.get() and shows exactly which credential.id is currently live (handy since Google Password Manager's own UI shows the account name, not the raw ID). Full file, webauthn-demo-register.html can be found in the github — https://github.com/kawaidevelopment/passkey-tutorial/blob/main/webauthn-demo-register.html
04. Test 2 — embed the same origin in two iframes, side by side
At this point, no serve.json exists yet — nothing on the server side stops framing. This test uses the unprotected pair of files: webauthn-demo-top-open.html (on port 8731) embeds webauthn-demo-frame-open.html (on port 8732 — a genuinely different origin) twice, once plain and once with a permission attribute.
Open webauthn-demo-top-open.html
You can find the source files in https://github.com/kawaidevelopment/passkey-tutorial/blob/main/webauthn-demo-top-open.html and https://github.com/kawaidevelopment/passkey-tutorial/blob/main/webauthn-demo-frame-open.html
Click "Try get()" inside each frame and compare:
So the permission attribute is real, and it does gate whether the sign-in ceremony can even start inside a frame. That's worth sitting with for a moment, because it's the detail that's easy to over-read.
Why the "success" case still isn't a phishing method
Two things stayed true even in the box that succeeded:
- The permission is self-granted, not stealable. That
allowattribute lives on the embedding page's own HTML. It works exactly likeallow="camera"— it's a site opting its own iframe into something, never a lever an outside attacker can flip on someone else's page. Copying a real login page into a scam page and addingallow="publickey-credentials-get"does nothing on its own, because a well-configured real server was never going to serve the page into that frame to begin with — see Test 3, where adding just two headers closes this off entirely. - The result stays bound to the framed content's real origin. The signature that comes back can only ever authenticate that exact site — it's not a token the outer page can read, replay, or redirect elsewhere. Layered on top of that is an older, unrelated browser rule — the Same-Origin Policy — which stops the outer page from reading the framed page's cookies or session data even after a real, successful login. Two independent walls, not one.
Field note — what framing** does ****still risk_ Not credential theft, but __clickjacking**__: disguising or hiding a legitimately-loaded frame so a user clicks something inside it without meaning to (an "Allow" button, say). It's a UI trick, not a data-theft trick — the attacker still learns and steals nothing. It's the separate reason serious sites block framing outright rather than leaning on WebAuthn's protections alone._
05. Test 3 — closing the loop
This test uses a separate pair of files from Test 2 — webauthn-demo-frame.html and webauthn-demo-top.html (no -open suffix) — so there's no ambiguity about "the same file behaving differently depending on when you look at it." These two are structurally identical to Test 2's pair (same layout, same two iframes, same button behaviour) — the only difference is webauthn-demo-frame.html doesn't carry Test 2's "UNPROTECTED" warning banner, because you're about to make that true by adding a header config next to it.
Create serve.json in the same folder — this is the same kind of anti-framing header a real identity provider relies on, applied only to the framed content, not the page hosting it:
{
"headers": [
{
"source": "webauthn-demo-frame.html",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" }
]
}
]
}{
"headers": [
{
"source": "webauthn-demo-frame.html",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" }
]
}
]
}Restart both serve processes (Ctrl+C, then run the same two commands from Setup again) so they pick up the new config, then open webauthn-demo-top.html and hard-refresh (Ctrl+Shift+R) to bypass any cached copy of the frame content.
Result: both columns now fail identically. The box with no allow attribute and the box with allow="publickey-credentials-get" both show the same broken-page icon — neither loads at all. In Test 2, the allow attribute made a real, visible difference between the two columns; here it makes none, because nothing loads far enough for it to matter. Server-side blocking sits upstream of the permission-delegation question — once the content itself is refused, there's nothing left downstream for allow to grant access to.
You can find the source files in https://github.com/kawaidevelopment/passkey-tutorial/blob/main/webauthn-demo-top.html and https://github.com/kawaidevelopment/passkey-tutorial/blob/main/webauthn-demo-frame.html
06. The full picture
Four independent layers, and a phishing iframe has to get past all four to be worth anything. A real login configured the way Test 3 demonstrates never even reaches the second layer — a phishing iframe doesn't get past the first.
Bottom line:_ Iframe-based phishing does not work against passkeys. Not because any single check happens to catch it, but because the whole design — server-side, browser-side, and cryptographic — assumes an attacker will try exactly this, and closes it at multiple independent points._
What passkeys don't cover
Framing the real page is one attack shape. A look-alike clone — a scam page that merely resembles the real one, rather than embedding it — is a different one, and it's exactly where passkeys are strongest: a clone has no way to trigger a valid signature for the real site at all, so at worst it can only phish a fallback password from an account that still allows one. Which is the practical takeaway: if you've set up a passkey, check whether password sign-in is still enabled alongside it, and turn it off if you want the full benefit.
Every step in this tutorial runs on a local practice site under your own control — no real account or third-party service involved anywhere.
References
- MDN — CredentialsContainer.create()
- MDN — CredentialsContainer.get()
- MDN — PublicKeyCredential (documents the top-level-only restriction on registration)
- MDN — Permissions-Policy: publickey-credentials-get
- MDN — Secure Contexts
- W3C — Web Authentication (WebAuthn) Level 3