Passkey authentication offers clear advantages over traditional usernames and passwords. It relies on public key cryptography, where the private key never leaves the client device, ensuring it can’t be stolen in transit or from a server. Only the public key is stored on the server, which is useful only for validating login attempts but not making them, reducing the risk of account takeover even if the server is breached.
In a previous post, we discussed how to create passkey credentials and how the server's URL is used to generate these credentials, making them more resistant to phishing. Using this technique, a credential signed for duendesoftware.com can not be used on example.org.
But what about subdomains? Or applications that operate globally and require users to be able to log in on several top-level domains? In this post, we'll cover origins in more detail, examine how to use passkeys across (sub)domains, and why you may want to consider tying passkey authentication to a specific URL.
Other posts in this series:
- An Introduction to Passkeys - The Future of Authentication
- Passkeys in .NET 10 Blazor Apps with ASP.NET Identity
- Deep-Dive Into Relying Party ID and Origin With Passkeys (this post)
Passkeys, Relying Party ID, Origin
When we looked at passkey support in .NET 10, we observed how the server sends passkey creation or attestation options to the client, including the challenge to sign, supported cryptographic algorithms, requirements for the authenticator, and additional information. This JSON payload also contains an rp
object, which is used to communicate to the client what the Relying Party ID for the server is.
{
"rp": {
"name": "localhost",
"id": "localhost"
},
// ...
"challenge": "TMdX6DBNi3Sz7wqK4cog0VUKoWg1xyxzAGQPeBX5XAY",
// ...
}
You'll notice the rp
object contains both a name
and id
. The name
is typically not used, but is included for backward compatibility. The id
is more critical: it is the domain (without protocol, port, and path) where the passkey can be used.
When the client creates a signed credential, it returns a JSON payload containing the credential ID, which the server uses to locate a stored passkey. The signature is included, along with the public key and more.
{
// ...
"id": "G5TKneiLXfq-uHHoXd6I4AVJivr8ht_U0ywP5KNxzLw",
"rawId": "G5TKneiLXfq-uHHoXd6I4AVJivr8ht_U0ywP5KNxzLw",
"response": {
// ...
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoienFoZ3dscmc0T2luWjBFNEg2MFBCZy03TmhrcldWNkc4ZWdYYVdFZ1hkZyIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0OjcyMTciLCJjcm9zc09yaWdpbiI6ZmFsc2UsIm90aGVyX2tleXNfY2FuX2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgifQ",
// ...
},
// ...
}
The clientDataJSON
field is the most interesting in relation to the Relying Party ID. It's a Base64-encoded JSON object that contains the challenge the server requested a signature for, and also the origin (or URL) for which the browser generated the passkey, and whether the passkey request was cross-origin:
{
"type": "webauthn.create",
"challenge": "zqhgwlrg4OinZ0E4H60PBg-7NhkrWV6G8egXaWEgXdg",
"origin": "https://localhost:7217",
"crossOrigin": false,
"other_keys_can_be_added_here": "do not compare clientDataJSON against a template. See https://goo.gl/yabPex"
}
Two validations of Relying Party ID and origin occur when creating or authenticating with a passkey:
- The browser verifies the Relying Party ID against the current URL.
- The server verifies the
origin
against its known Relying Party ID. Note that the server can choose to allow other origins as well; we'll look at this further in this article.
I realize that's a lot of detail to come to one conclusion, but it's important. Since a passkey contains a required relation between the server's Relying Party ID and the URL, you can not easily change the application URL. The browser would not provide a passkey as the URL doesn't match, and the server will reject a passkey that has no matching origin. In other words, changing the URL would invalidate all passkeys that have ever been used.
There are exceptions to this rule, as there always are. We'll look at subdomains next, followed by related origins. As a general rule, remember that once a credential is created for a Relying Party ID, it's impossible to change the origin URL.
Passkeys And Subdomains
The WebAuthn spec describes how the Relying Party should validate the origin, where a passkey implementation can decide on specific validation rules for subdomain origins. Since browser implementations are fixed, these are more lenient towards using subdomains and generally allow creating a passkey for a Relying Party ID when the origin is more specific.
Here are some examples of how the browser can support creating a passkey credential on www.example.com
while the Relying Party ID is less specific (example.com
), but not the other way around.
Origin | Relying Party ID | |
---|---|---|
https://example.com | example.com | ✅ |
https://www.example.com | www.example.com | ✅ |
https://www.example.com | example.com | ✅ |
https://example.com | www.example.com | ⛔️ |
The browser also uses the public suffix list here to ensure top-level domains like .co.uk
are not all treated as potentially the same co.uk
Relying Party ID.
As mentioned, the server can choose how to validate the origin against its Relying Party ID. By default, the passkey implementation in .NET 10 uses the server hostname as the Relying Party ID, and requires the Relying Party ID and origin to match. Cross-origin requests are considered evil by default, so you will find a check for that on line 666 😈 in the framework's PasskeyHandler
.
This behaviour can be changed in your application's startup code, where you can set a custom server domain (used as the Relying Party ID), and optionally add a ValidateOrigin
callback to validate the origin and support specific subdomains.
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.ServerDomain = "example.com";
options.ValidateOrigin = c => ValueTask.FromResult(
!c.CrossOrigin ||
c.Origin == "https://example.com" ||
c.Origin == "https://subdomain.example.com");
});
As we've seen, using subdomains with passkeys is relatively straightforward. They come, however, with a security gotcha.
Subdomains, Multi-Tenancy, and User-Generated Content
In scenarios where you are implementing multi-tenancy based on subdomains, or where your application requires passkey support across a large number of subdomains, it becomes tempting to set the Relying Party ID to be more general and allow all subdomain origins.
In .NET 10, you could configure this as shown below - but it’s strongly discouraged.
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.ServerDomain = "example.com";
options.ValidateOrigin = c => ValueTask.FromResult(
!c.CrossOrigin ||
c.Origin.StartsWith("https://") &&
c.Origin.EndsWith("example.com"));
});
If server side validation is lax, like in the previous example, the browser will allow sharing passkey credentials across all of your subdomains. Let's illustrate this with a table of origins and the Relying Party ID again:
Origin | Relying Party ID | |
---|---|---|
https://example.com | example.com | ✅ |
https://customer1.example.com | example.com | ✅ |
https://customer2.example.com | example.com | ✅ |
https://user-content.example.com | example.com | ✅ |
In this case, any tenant can use a passkey that is valid for another tenant.
In addition, if you allow hosting HTML and JavaScript on the user-content.example.com
subdomain, either intentionally or accidentally, you're opening up the possibility for malicious actors to create, use, and exfiltrate passkey credentials that are valid for your applications.
While the reliance on a server-side challenge helps with replaying passkey credentials, allowing user content with lax validation of the origin on the server eliminates one security feature of WebAuthn.
Luckily, there are several solutions to using passkeys in combination with subdomains for multi-tenancy or user-generated content:
- Use strict validation of subdomain origins on the server, ideally, matching verification against known origins.
- Do not use subdomains for user-generated content. If needed, host it on
user-content.com
rather thanuser-content.example.com
. - Have all tenants and subdomains use a common authentication endpoint on a separate (sub)domain, using a single and specific Relying Party ID. For example,
login.example.com
could host a Duende IdentityServer that supports passkey authentication only on that subdomain, and has a known set of clients configured using OpenID Connect.
Passkeys And Multiple Domains
Let's consider another scenario. An enterprise that is active in many countries has several top-level domains in use, and a passkey credential is expected to be valid across all of them. For example, a global book store could be hosted on example.com
, example.co.uk
, example.au
, and many more.
The server communicates only one Relying Party ID, and given the other top-level domains would not be allowed by the browser as they are not subdomains, the following configuration would be useless:
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.ServerDomain = "example.com";
options.ValidateOrigin = c => ValueTask.FromResult(
!c.CrossOrigin ||
c.Origin == "https://example.co.uk" ||
c.Origin == "https://example.au");
});
In the case of multiple domains being used, you can implement Related Origin Requests (ROR), where a Relying Party can provide a list of valid origins for a given Relying Party ID (RP ID).
This list can be hosted on a well-known endpoint on your Relying Party ID's domain (e.g., https://example.com/.well-known/webauthn
), and on all of the related origin domains. The list of related origins is served as a JSON-formatted list:
{
"origins": [
"https://example.com",
"https://example.co.uk",
"https://example.au",
"https://examplepromotions.com"
]
}
Clients supporting this feature must support at least five origin labels. While most browsers appear to support more than five, it is safe to assume there will be an upper limit to prevent abuse.
While Related Origin Requests are helpful when working with multiple domains, they are more cumbersome to maintain across all the supported origins. Not all browsers support the same number of entries in the list (and it's hard to find documented limits for each browser).
These issues are manageable, but it is worth considering a common authentication endpoint, such as Duende IdentityServer, which all domains may use.
Conclusion
When deploying passkey support, it's important to consider the relationship between a passkey's Relying Party ID and its origin URL. This relationship is fundamental to how passkey credentials are created and validated, and is a core aspect of making passkey authentication resistant to phishing. This also means that changing an application's URL can render existing passkeys invalid.
We've explored how subdomains are handled more flexibly by browsers, allowing for a broader Relying Party ID to cover more specific origins. Careful server-side validation is crucial to avoid security pitfalls, particularly with multi-tenancy or user-generated content.
For applications spanning multiple domains, Related Origin Requests (ROR) offer a solution, albeit with some maintenance overhead.
Ultimately, for complex scenarios involving numerous subdomains or multiple top-level domains, a dedicated and centrally managed authentication endpoint, such as one provided by Duende IdentityServer, can offer a more secure and manageable approach to passkey implementation.
In the next post, we'll see how you can use ASP.NET Identity in .NET to implement passkey support with Duende IdentityServer.
As always, we welcome your thoughts and feedback in the comments below.