Multi-Origin Passkey Setup
This page explains how to configure passkeys to work across multiple origins (subdomains) sharing the same Relying Party (RP) ID.
When You Need This
Single domain: If your application runs on a single domain (e.g., https://example.com), you do NOT need this configuration.
Multiple origins: You need this when:
- Your app runs on multiple subdomains (e.g.,
app.example.comandlogin.example.com) - You want passkeys registered on one subdomain to work on another
- You’re sharing authentication across development and staging environments
How It Works
WebAuthn passkeys are bound to a Relying Party ID (RP ID), not to a specific origin. By default, the RP ID matches your domain.
RP ID: example.com
Allowed Origins:
├── https://example.com (main site)
├── https://app.example.com (application)
└── https://login.example.com (login portal)
All these origins share the same RP ID (example.com), so a passkey registered on any of them works on all of them.
The /.well-known/webauthn endpoint tells browsers which origins are allowed to use this RP ID.
Configuration
Step 1: Set Environment Variables
# .env
# Your main origin
ORIGIN=https://example.com
# The RP ID (usually your root domain)
PASSKEY_RP_ID=example.com
# Additional origins that can use the same passkeys
WEBAUTHN_ADDITIONAL_ORIGINS=https://app.example.com,https://login.example.com
Step 2: Use the Unified Router
If you use oauth2_passkey_full_router() (recommended), the /.well-known/webauthn endpoint is automatically included when WEBAUTHN_ADDITIONAL_ORIGINS is set:
use axum::Router;
use oauth2_passkey_axum::{oauth2_passkey_full_router, init};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init().await?;
let app = Router::new()
// All auth routes + /.well-known/webauthn (when multi-origin is configured)
.merge(oauth2_passkey_full_router());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Alternative: If you use oauth2_passkey_router() directly, add the well-known router manually:
use axum::Router;
use oauth2_passkey_axum::{
oauth2_passkey_router, passkey_well_known_router,
init, O2P_ROUTE_PREFIX
};
let app = Router::new()
.merge(passkey_well_known_router())
.nest(O2P_ROUTE_PREFIX.as_str(), oauth2_passkey_router());
Step 3: Verify Setup
After starting your server, verify the endpoint returns the correct configuration:
curl https://example.com/.well-known/webauthn
Expected response:
{
"rp_id": "example.com",
"origins": [
"https://example.com",
"https://app.example.com",
"https://login.example.com"
]
}
Important Notes
RP ID Requirements
The RP ID must be a parent domain of all origins:
| RP ID | Origin | Valid? |
|---|---|---|
example.com | https://example.com | Yes |
example.com | https://app.example.com | Yes |
example.com | https://other-site.com | No |
Browser Support
Related Origins is supported in modern browsers. Check caniuse.com for current browser support.
Security Considerations
- The
/.well-known/webauthnendpoint exposes only public configuration (RP ID and allowed origins) - No authentication is required for this endpoint (browsers fetch it before authentication)
- Only list origins you actually control
Troubleshooting
Passkey not working on subdomain
- Verify
WEBAUTHN_ADDITIONAL_ORIGINSincludes the subdomain - Check that
/.well-known/webauthnis accessible from the subdomain - Ensure the RP ID is a parent domain of all origins
404 on /.well-known/webauthn
- If using
oauth2_passkey_full_router(): VerifyWEBAUTHN_ADDITIONAL_ORIGINSis set - If using
oauth2_passkey_router()directly: Add.merge(passkey_well_known_router())to your router
Origins list is empty
Check that ORIGIN environment variable is set correctly.