Passkeys Across Domains
There’s a plan for passkeys to work across vendors, but we’re not there yet. Here’s how they can work across domains though, and it works today.
intro
Last week at the Authenticate conference there was news about a potential path for passkeys to extend beyond the vendor silos in which they currently dwell. But that solution is a bit early, so I thought I’d provide an update about another way passkeys are “breaking out” beyond some historical limitations, and what solutions exist today.
In a previous post, I covered how passkeys (and WebAuthn/FIDO credentials) are resource specific. That means credentials are created per-resource and are tied to the app’s web origin or associated domain equivalent for mobile apps. It’s one of the ways WebAuthn credentials protect privacy. (That’s, of course, in addition to using private keys that are never sent over the wire.)
This privacy protection comes with a couple of trade-offs. First, you end up having a bunch of keys and credentials (we’ve covered that on this blog). Second, it becomes challenging for resources on different domains to use the same credential. Because, well, that’s the whole point! A bunch of different domains shouldn’t be able to use the same credential.
But there are reasons some want to.
This blog post walks through what is and is not possible with passkeys across different domains, including subdomains, domain suffixes and TLDs.
a few terms
In this blog post I make heavy use of a few terms. Here they are, with definitions:
RPID: WebAuthn Relying Party ID, the parameter used optionally in both WebAuthn create() and get() calls
create() and get() calls: WebAuthn compliant API calls exemplified by the browser APIs navigator.credentials.create and navigator.credentials.get(), and corresponding mobile and platform WebAuthn API calls
origin / web origin: the “host” portion of a URL, such as “www.example.com” in “https://www.example.com/”
starting simple
single app on a single domain
Let’s start by looking at how one single domain works with passkeys. To illustrate, I’ve hosted my webauthn test app “Fun with WebAuthN” on my own domain at app.goodsignin.com. (I had to bump myself up from the free to the basic pricing tier to add a custom domain!) Importantly, the DNS is an A record so that the web origin of the app is the custom domain name:
Webauthn sample web app with web origin app.goodsignin.com
enroll a passkey
By default, if I enroll a credential there, the RPID associated with the credential will be app.goodsignin.com. This is because the default RPID associated with a passkey is the app’s web origin.
Windows Hello passkey for RPID app.goodsignin.com
use the passkey
If I then return to the same app to sign in (the app hosts both registration and sign on at the same domain app.goodsignin.com), I will be able to use my new Windows Hello passkey for username 23887@goodsignin.com.
Passkey signin prompt in Chrome browser for local Windows Hello credential
I won’t be able to use this credential anywhere else: just app.goodsignin.com.
adding a twist - domains and subdomains
passing the ‘RPID’ parameter
Keeping the same app in the same place (app.goodsignin.com), now lets enroll a second credential. Only this time, I’ll send the RPID parameter as part of the navigator.credentials.create request. And, instead of app.goodsignin.com, I’ll send as RPID the domain suffix goodsignin.com (the spec allows you to do this).
Public key credential creation options object showing RPID parameter “goodsignin.com”
enrolled passkey
This results in a passkey for user 39008@goodsignin.com with RPID goodsignin.com:
Windows Hello passkey for RPID goodsignin.com
using the passkey(s)
When I come back to the app to sign in, I can use either of the passkeys I created above. The only trick is that, to use the goodsignin.com credential, I have to make sure I provide goodsiginin.com as the RPID parameter in the sign on request, just as I did for creation.
Public key credential request options object showing RPID parameter “goodsignin.com”
So, depending on whether I pass the RPID parameter as shown above, the same app at the same origin will prompt me for the new 39008@goodsignin.com passkey or the original 23887@goodsignin.com passkey, as shown below:
summary: RPID parameter with subdomains
With this trick, one can imagine a company with tons of subdomains under example.com: benefits.example.com, shop.example.com, www.example.com, whatever you want. You will have a way to enroll a passkey that works across all. You just have to remember to:
Send RPID parameter “example.com” on the webauthn create call
Send RPID parameter “example.com” on each webauthn get call across all the subdomains/properties.
Great so far, right?
wait - does this mean i can just create passkeys for a popular subdomain or tld?
Thankfully no.
public domain suffix
This is because azurewebsites.net is a public domain suffix (and not a registrable one, per the WebAuthn spec terminology). Public domain suffixes are on a well known list that is excluded by the WebAuthn protocol because they allow people to register subdomains under them. To illustrate, let’s try to use the trick we used above to create passkeys that will work for subdomains under azurewebsites.net (a Microsoft domain where Azure customers can host web apps.)
First, I’ve hosted the same web app we used above at funwithwebauthn.azurewebsites.net
Webauthn sample web app with web funwithwebauthn.azurewebsites.net
attempting to enroll a passkey for the public suffix
We’ll go ahead and try sending suffix “azurewebsites.net” as the RPID:
Public key credential creation options object showing RPID parameter “azurewebsites.net”
security error
Only this time, when we try to send RPID “azurewebsites.net”, we get the error message “WebAuthn create had an error: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.”
Webauthn client returns a SecurityError in response to RPID azurewebsites.net
summary: public domain suffixes and the ‘RPID’ parameter
So, to recap, no, you can’t use a publicly shared domain suffix like azurewebsites.net, github.io, etc as a passkey RPID.
For passkeys, you can and should use as RPID a domain that you own and for which you control who can register sub-domains.
Ok, great. So now we know the domain suffix override cannot be abused. However it still has limitations. It does not allow for the internationalization of goodsiginin.com into goodsignin.co.uk, etc, because these are not domain suffixes but completely distinct domains.
passkeys across disparate domains
Goodsignin.com has gone international! We now have goodsignin.co.uk, goodsignin.ch, goodsignin.kr, … you get the picture.
The scheme I described above breaks because these new domains are disparate domains, meaning they do not share common domain suffixes at all. What can be done?
federation vs related origin requests
Now, the official FIDO people strongly recommend that you use federation. This means enrolling and using passkey credentials at one common domain address (think login.example.com), and then issuing tokens from that site to other relying party sites. Various federation protocols such as OAuth/OpenID Connect, SAML, and WS-Federation can accomplish this. This however means that you are not using WebAuthn/FIDO protocols to sign in to the relying party sites. Rather, you’re back in token land and therefore subject to token compromise.
The other option is to use a new capability that has emerged in the WebAuthn world this year, called related origin requests.
Related origins for app.goodsignin.com
about related origins
Related origins is a way to have a family of disparate domains all able to use the same passkey credential for a user.
This is done by posting a list of origins at a publicly defined, well known endpoint, as pictured above. Related origins are defined in the latest version of the WebAuthn specification. So far, they’re supported in Chrome and Edge on most devices, as you can see under “Advanced Capabilities” on the passkeys.dev device support page.
With related origins, you can choose one “home” domain – it could be for example login.example.com, or example.com, or something completely else. This domain origin will do two things:
Host metadata at endpoint “https://{home domain}/.well-known/webauthn” that specifies a list of origins allowed to use passkeys bound to the home origin (see the image above for example and format)
Serve as the RPID that will be bound to enrolled passkeys, across all of the related domains
Let’s walk through it.
how it works - example
Let’s say I have both of the two WebAuthn relying party apps mentioned above live, respectively, at the domains app.goodsignin.com and funwithwebauthn.azurewebsites.net. For example purposes, these are our disparate domains (I don’t actually own goodsignin.co.uk or any of the others, sorry).
I have related origins JSON metadata hosted on app.goodsignin.com, as shown in the image above.
Now, I enroll a passkey at funwithwebauthn.azurewebsites.net, specifying app.goodsignin.com as RPID.
As a result, I have a new passkey that I can use:
- at the funwithwebauthn.azurewebsites.net app (provided the request specifies RPID app.goodsignin.com)
- at app.goodsignin.com (without the RPID parameter)
That’s it! It’s almost so easy it’s confusing.
So how is this enabled?
how it works - flow
When a WebAuthn request using related origins is processed, the following happens:
1) If an RPID parameter is passed in the options object, either for create or get, the WebAuthn client evaluates it as the intended RPID
if the passed RPID matches the app’s actual web origin, all is good and the RPID value is used for the request
if not, but if the passed RPID it is a non-public domain suffix of the actual web origin, all is good and the RPID value is used for the request
Failing both of the above, the passed RPID value is used to construct the well-known related origins URL (pre-pended with “https://” and appended with “/.well-known/webauthn”) and that endpoint is checked for an entry containing the current site’s actual web origin. If a matching entry is found, all is good and the passed RPID value is used for the WebAuthn request.
2) Unless another error occurs (failed user verification, authenticator not found, etc) the RPID resulting from step 1 above will be either
(for create requests) associated with the new WebAuthn credential, or
(for get requests) used to find the credential and generate a WebAuthn assertion.
3) Importantly, irrespective of the passed RPID, it is the app’s real web origin that is returned in the clientDataJSON member of the WebAuthn response, and this is what the back end must validate. More on this and other back end requirements in the next section…
how it works - back-end requirements
Whether you have a home-grown back end or a commercial solution for verifying passkey responses, there are a few new requirements you’ll need to make sure the back end meets.
Verify all origins
As I mentioned above, the origin sent to the back end (in clientDataJSON) will always be the actual web origin and not the RPID passed, whether for create() or get(). So if for example you had a common back end evaluating all responses, it would have to know about any and all related origins.
User database
In addition to checking the various origins, your back end verification services need to have a common understanding of user data, whether this by via sync fabric, a common user database or user validation API, or another solution. This would be true in any scenario in which separate services create and use common credentials, not just passkeys.
RPID context for users
Also as I called out above, you must ensure the RPID parameter is sent properly in WebAuthn create() and get() calls when it is needed.
For scenarios like I described above where there is one “home” domain associated with all passkeys, RPs could use one standard RPID (the domain where the metadata is hosted) and just send it for all create() and get() calls.
An alternative to this would be for create() calls to use the default web origin of the app where the passkey is being created. This requires a “full mesh” of related origins metadata posted at each possible domain, as is described in the passkeys.dev writeup. In this solution, the RPID is made part of the stored credential in the user database so that the correct RPID can be retrieved and used for get() calls in a username based flow, though it’s unclear how this would work for a username-less flow. Because the writeup comes from the FIDO team I take their recommendation seriously.
It seems like the common RPID approach or the “full mesh” approach can work well, depending on your environment. I leave it up to the reader to evaluate both.
conclusion
By default, passkeys can be enrolled and used across multiple domains that share a common, registrable domain suffix.
In order to use passkeys across disparate domains that do not share a common suffix, your options are to use federation (for example, OAuth / OpenID Connect token protocols) or to deploy related origins as specified in the latest WebAuthn protocol specification. For more information about it, check out the write-up at passkeys.dev.