Authio docs

Guides

WebAuthn RPID for custom domains

Passkeys are origin-bound. The moment a customer switches from auth.authio.com to auth.acme.com (or from auth.acme.com to auth-v2.acme.com), the WebAuthn RPID changes \u2014 and the user's existing passkey stops working. This guide explains the model, what breaks when, and how to migrate without stranding users.

WebAuthn is a phishing-resistant authentication standard, and the mechanism that makes it phishing-resistant is also the mechanism that makes it fragile: every passkey is bound to a specific Relying Party Identifier (RPID). Change the RPID and the credential the user enrolled previously will not be offered by the browser's passkey UI on the new origin.

This guide explains the RPID model, how Authio resolves the RPID per request, where this breaks in real customer migrations, and the playbook for moving users between origins without stranding them.

1. What an RPID actually is

The WebAuthn spec defines the Relying Party Identifier as “a valid domain string that identifies the WebAuthn Relying Party.” In practice it's either the host of the calling origin (default) or a registrable suffix of that host (explicit, with rp.id in the create/get options).

  • Origin https://auth.acme.com → default RPID auth.acme.com. Can be lowered to acme.com by setting rp.id = "acme.com" in the create options.
  • Origin https://auth-v2.acme.com → default RPID auth-v2.acme.com. Can be lowered to acme.com.
  • Origin https://auth.authio.com → default RPID auth.authio.com.

The browser remembers, against each enrolled credential, the RPID it was created with. On a later navigator.credentials.get() call, the browser will only offer credentials whose stored RPID matches the current page's RPID. There is no “trust on first use,” no “suggested credential,” no manual override. By spec.

2. How Authio resolves RPID per request

Auth-core resolves RPID dynamically from the calling request's Origin header, looked up against the project's registered custom domains. The path is deterministic and cached per origin:

request arrives → parse Origin header
  → Origin host is auth.acme.com
    → look up custom_domains where domain='auth.acme.com'
       AND status='active' AND project_id matches the calling project
    → row hit: use rp_id from the row (default: domain itself)
    → row miss: use the project's default RPID
       (typically auth.authio.com for unbranded tenants)
  → emit ResolvedRPID in the WebAuthn options

Resolution is cached with a positive + negative TTL keyed on the full origin (scheme + host); writes to custom_domains emit a POST /internal/passkey/invalidate-origin so the new RPID is in effect within a fraction of a second of the config change.

The same resolver is used for both navigator.credentials.create() (registration) and navigator.credentials.get() (authentication). Asymmetry between the two is the primary cause of customer- visible bugs in DIY WebAuthn implementations; Authio collapses that into a single resolver.

3. The most common breakage

Customer onboards. Their users enroll passkeys at auth.acme.com. Six months later, the customer rebrands or restructures and wants to move to auth-v2.acme.com or auth.acmecorp.io. They add the new custom domain in the dashboard, flip is_default, and email users that the URL has changed.

The next day, support is flooded with “my passkey is gone” tickets. The passkey isn't gone — it's in iCloud Keychain or the Android passkey manager, fine. But the browser refuses to offer it at the new origin because the RPIDs don't match.

There is no client-side fix. By WebAuthn spec, the user must re-enroll at the new RPID. The question is whether Authio makes that pain visible or invisible.

4. Mitigation A — anchor RPID at the root domain

If you control the entire acme.com root and you anticipate moving subdomains, set the RPID one level higher than the default. WebAuthn permits an RPID to be a registrable suffix of the origin's host: a passkey created with rp.id = "acme.com" on auth.acme.com will continue to work on auth-v2.acme.com, login.acme.com, and portal.acme.com — anywhere under acme.com.

In Authio, set the RPID by editing the custom-domain row:

PATCH /v1/session/projects/{project_id}/custom-domains/{id}
{ "rp_id": "acme.com" }

Authio validates that rp_id is a registrable suffix of domain — you can't set rp_id to notacme.com on auth.acme.com, and you can't set it to a public suffix like com or co.uk (the validator uses the public-suffix list).

Anchor at the root the first time you set up a custom domain, not the second. The first passkey enrolled sets the RPID for that credential forever; you can't lower it later without re-enrolling every user.

5. Mitigation B — RPID upgrade flow

If users already have passkeys enrolled at the subdomain RPID and you want to move them to a root-domain RPID (or to a different subdomain), Authio supports a guided re-enrollment flow in the hosted UI:

  1. User visits the new origin (auth-v2.acme.com).
  2. Hosted-UI offers magic-link or OAuth sign-in (the user can't use the old passkey here — wrong RPID).
  3. After successful sign-in, hosted-UI checks user.passkeys and detects credentials enrolled at a different RPID for this user.
  4. UI prompts: “Your existing passkey was set up at the old URL. Set up a passkey for the new URL?” with Skip and Enroll CTAs.
  5. On enroll, the browser creates a new credential at the new RPID. The old credential row stays in webauthn_credentials (the customer can revoke it later via the user-management API) so the user can still sign in at the old origin during the cutover.

Concretely the new origin emits auth.webauthn_rpid_mismatch in the audit log each time a user signs in with a fallback method (not passkey) and has at least one passkey enrolled at a different RPID. Dashboards can use that to track the re-enrollment rate during a cutover.

6. Code example — create options request

For Authio customers building a custom sign-in UI (not the hosted-UI), the WebAuthn create/get options endpoints return the resolver's RPID for the current request:

// Frontend: request create options for the current origin.
const res = await fetch('https://auth.acme.com/v1/auth/passkey/register/options', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ user_id: 'user_01HX...' }),
  credentials: 'include',
});
const options = await res.json();
// options.rp.id will be:
//   - 'acme.com'        if the custom_domains row has rp_id='acme.com'
//   - 'auth.acme.com'   if rp_id is null (default to the host)
//   - 'auth.authio.com' if the request didn't match a custom domain

const credential = await navigator.credentials.create({
  publicKey: options,
});

Authio's React SDK (@authio/react) abstracts this in the <SignIn /> component, but the underlying call is identical.

7. Mobile considerations

iOS · Associated Domains

For passkeys to work in an iOS app (vs. mobile Safari), the app must declare an Associated Domain that matches the RPID. Add to the app's Entitlements.plist:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>webcredentials:auth.acme.com</string>
  <string>webcredentials:acme.com</string>
</array>

And publish at the registered host:

# Hosted at https://acme.com/.well-known/apple-app-site-association
{
  "webcredentials": {
    "apps": [ "TEAMID.com.acme.app" ]
  }
}

Authio doesn't serve AASA on the custom-domain origin for the customer's native app — the AASA is the customer's responsibility on their root domain. Authio does serve AASA on auth.authio.com for the hosted-UI's own use.

Android · Digital Asset Links

Equivalent setup for Android. Publish at:

# Hosted at https://acme.com/.well-known/assetlinks.json
[{
  "relation": ["delegate_permission/common.get_login_creds"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.acme.app",
    "sha256_cert_fingerprints": ["..."]
  }
}]

The Authio Kotlin and React Native SDKs document the respective platform setup in their READMEs.

8. Subdomain considerations

Default: bind to the subdomain

The simplest model and the spec default. Each subdomain is its own trust boundary, no leakage between them. If you operate a multi-tenant app on subdomains (tenant1.acme.com, tenant2.acme.com), per-subdomain RPIDs prevent a phished passkey at one tenant from being usable at another.

Anchor at the root

Use when the auth surface is one logical domain (auth.acme.com,login.acme.com) and you want users to move freely between them with one passkey. Set rp_id = "acme.com" on the custom-domain row.

Mixed mode — not recommended

Two custom-domain rows under the same root, one with rp_id = "acme.com" and the other with rp_id = "auth.acme.com": users who enrolled on the first row will work on both origins; users who enrolled on the second will only work at the second origin. The asymmetry confuses support. Pick one model per project.

9. Compatibility matrix

How RPIDs interact across common origin/Domain combinations. “Enrolled at” is the RPID stored against the credential at creation time; “Used at” is the origin the browser is currently on.

Enrolled at (RPID)Used at (origin)Works?Why
auth.acme.comauth.acme.comYesExact match.
auth.acme.comauth-v2.acme.comNoRPID is a sibling subdomain.
auth.acme.comacme.comNoRPID is more specific than origin host.
acme.comauth.acme.comYesOrigin host is a subdomain of the RPID.
acme.comauth-v2.acme.comYesSame root-anchor — this is the migration win.
acme.comapp.acme.comYesRPID covers every subdomain under acme.com.
acme.comnotacme.comNoDifferent registrable domain.
auth.authio.comauth.acme.comNoThis is the post-custom-domain-cutover case — users must re-enroll.
auth.acme.comauth.authio.comNoReverse of the above — same constraint.

10. Operational checklist

  • Set rp_id at the root domain the first time you add a custom domain, if you may ever move subdomains.
  • Plan a re-enrollment window before any cutover that changes the RPID. Tell users in advance.
  • Run both old and new origins in parallel for at least a billing cycle so users can fall back to magic-link without confusion.
  • Track auth.webauthn_rpid_mismatch in your audit dashboard to see the re-enrollment curve.
  • Update AASA / Digital Asset Links files at the customer's root domain to match the chosen RPID.
  • Leave old webauthn_credentials rows in place for one billing cycle, then sweep them via the management- API.

See also