Originally published in Japanese on Zenn: passkeyを環境を超えて使い回したい ブラウザ編
Goal
A passkey is a key that is bound to a specific site. A passkey created on wallet.example.com cannot be seen by pages on other domains or browser extensions.
However, when building apps, you often want to do things like this:
- Derive the same secret using the same passkey in a web application on a different domain.
- Use an arbitrary site's passkey from a browser extension.
This article covers two approaches that use only browser standards, backed by code that actually ran. Native apps and OS mechanisms are a topic for another post.
What is a passkey? A quick mental model 🧩
A passkey is a WebAuthn (FIDO2) key pair.
- The private key stays securely stored within a provider (such as iCloud Keychain, Google Password Manager or 1Password) and never leaves.
- The public key is sent to the site.
- When the site asks you to 'sign this challenge', the provider will return a signature after user verification (UV), such as Touch ID.
The important constraint is the RP ID (Relying Party ID). A passkey is bound to an RP ID (usually the site's hostname) at the time of creation, and the browser strictly checks whether the calling page is permitted to claim that RP ID. This is why passkeys are resistant to phishing, but also why they cannot be used freely in another environment.
The PRF extension is also available (a pseudo-random function extension). Rather than providing a signature, it enables you to extract a 32-byte secret from the passkey.
PRF = HMAC(the passkey's internal random secret, salt) Using the same passkey and salt produces the same value every time.
Bottleneck: three things that must match 🎯
In order to obtain the same secret in a different environment, all three of these must match.
| What must match | If it doesn't |
|---|---|
| RP ID | The browser throws SecurityError. You never reach the passkey |
| The passkey (credential) itself | Calling create makes a new passkey. PRF differs per passkey, so values won't match |
| salt | Same passkey, different salt → different value |
The main topic of this article is how to get past the RP ID constraint.
Approach 1: Websites 🌐
1-a. Allow another origin with /.well-known/webauthn
This uses Related Origin Requests from WebAuthn Level 3. The domain that owns the RP ID can publish a list of origins permitted to claim its RP ID.
For example, place this file on the RP ID side (wallet.example.com).
// https://wallet.example.com/.well-known/webauthn { "origins": ["https://app.example.net"] } Serve it with
Content-Type: application/json.
This allows a page on https://app.example.net to call WebAuthn with an explicit rpId. No changes are needed to the calling site's server.
This is what creating the passkey on wallet.example.com looks like. If you omit rp.id, the browser will use the page hostname as the RP ID.
const SALT = new TextEncoder().encode('example/seed/v1') const cred = await navigator.credentials.create({ publicKey: { rp: { name: 'Example Wallet' }, // omit rp.id → RP ID is wallet.example.com user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'me', displayName: 'me' }, challenge: crypto.getRandomValues(new Uint8Array(32)), pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, extensions: { prf: { eval: { first: SALT } } }, }, }) const prfOnCreate = cred.getClientExtensionResults().prf?.results?.first // some providers return this on create Here is an example of getting PRF from the same passkey on a different domain (app.example.net). The only difference is an explicit rpId.
const cred = await navigator.credentials.get({ publicKey: { rpId: 'wallet.example.com', // ← the key point challenge: crypto.getRandomValues(new Uint8Array(32)), allowCredentials: [], // empty → passkeys for this RP ID show in the picker userVerification: 'required', extensions: { prf: { eval: { first: SALT } } }, // same salt as create }, }) const prf = cred.getClientExtensionResults().prf?.results?.first If the browser detects that rpId does not match the page origin, it fetches https://wallet.example.com/.well-known/webauthn and allows the call if origins contains the page origin. Otherwise, it throws SecurityError.
This worked in experiments across two *.vercel.app projects on the Public Suffix List (i.e. separate registrable domains).
The following constraints should be kept in mind:
- You can list a maximum of 5 eTLD+1s (
a.example.netandb.example.netcount as one). - Supported in Chrome/Edge 128+ and Safari 18+. Not verified in Firefox.
- The RP side must use HTTPS. It is not possible to validate with
localhostas the RP. - Negative confirmation (i.e. removing the allowlist entry and checking for
SecurityError) was not performed.
1-b. Parent/child subdomains need no well-known file
An RP ID may already be the page's hostname, or any of its parent domains up to the registrable domain.
Page: https://app.wallet.example.com Allowed rpId: app.wallet.example.com / wallet.example.com / example.com Not allowed: other.example.com / example.net Therefore, if you create the passkey with rp.id: 'example.com' from the outset, both wallet.example.com and app.example.com can get the same passkey. If you expect more subdomains later, using the parent as the RP ID at creation time is the easiest option. 🌱
However, a passkey already created with RP ID wallet.example.com cannot later be changed to use the parent RP ID. Existing users need approach 1-a.
Approach 2: Browser extensions 🧩
Here's how to use a site's passkey from a Chrome extension page (origin: chrome-extension://<id>).
The important point: Chrome skips the “RP ID must match the page origin” check for extensions that hold a host permission for the target site. Chromium even comments, “Allow claiming an RP ID if an extension can access this page.” So, declare the RP ID host in manifest.json.
{ "manifest_version": 3, "name": "Example passkey bridge", "version": "0.0.1", "host_permissions": ["https://wallet.example.com/*"] } Declare the same host as the RP ID. For RP ID
example.com, usehttps://example.com/*; forwallet.example.com, usehttps://wallet.example.com/*. Content-scriptmatchesdo not count.
The extension-page code is the same as the 1-a caller: call get with an explicit rpId.
const cred = await navigator.credentials.get({ publicKey: { rpId: 'wallet.example.com', challenge: crypto.getRandomValues(new Uint8Array(32)), allowCredentials: [], userVerification: 'required', extensions: { prf: { eval: { first: SALT } } }, }, }) Extension-specific notes:
- The call fails if the user turns off site access in the extension settings.
- The Chrome Web Store review will ask you to justify that host permission.
- This also means any extension with broad permissions like
https://*/*can do the same. The Touch ID prompt shows RPwallet.example.com, so users may not notice the difference. Services that derive seeds from passkeys should include this path in their threat model. ⚠️
Bonus: choosing allowCredentials 🪄
allowCredentials on get changes the UX:
- Empty: passkeys for that RP ID appear in the picker—use this the first time.
- Credential id set: Skip the picker and go straight to Touch ID. Use this from the second time onwards.
A credential ID is a public identifier, not a secret. You can store it and carry it across environments. It works on other devices synced by the same provider (for example, another Mac on iCloud or another Chrome on GPM). A different provider returns NotAllowedError, so fall back to an empty-array picker on failure.
Takeaways 📝
| Approach | What you place where | Who controls allow | Best for |
|---|---|---|---|
1-a /.well-known/webauthn | One JSON file on the RP ID server | RP lists origins explicitly | Cross-domain web apps; keep existing passkeys |
| 1-b Parent domain as RP ID | Set rp.id to the parent at create time | Stays inside one registrable domain | New passkeys; designs with growing subdomains |
| 2 Extension host permission | One line in manifest.json | User extension settings + Store review | Calling from a browser extension |
In every case, same RP ID + same passkey + same salt → same PRF. Confirming that was the biggest takeaway.
Not verified yet
- Differences between providers (iCloud Keychain, Google Password Manager and 1Password) have not yet been verified.
- Negative confirmation after removing the allowlist entry (removing the origin from origins in 1-a; using an undeclared host as
rpIdin 2) was not performed. - Does the PRF remain the same when using a hybrid (QR) with an iPhone?
- Firefox support for Related Origin Requests has not yet been verified.
References
- WebAuthn Level 3 — RP ID rules (§5.1.3), PRF extension, Related Origin Requests
- Chromium
chrome_web_authentication_delegate.cc— RP ID check exemption for extensions - Public Suffix List