Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

πŸ”‘ Reusing passkeys across environments β€” browser edition

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

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.net and b.example.net count 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 localhost as 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, use https://example.com/*; for wallet.example.com, use https://wallet.example.com/*. Content-script matches do 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 RP wallet.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 rpId in 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

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.