Biometric authentication should be seamless — tap your fingerprint, scan your face, and you're in. But when your WebAuthn Relying Party ID is tied to a specific subdomain, credentials registered on control.boottify.com won't work on boottify.com. We hit this wall when we expanded our authentication flow across multiple subdomains and needed a migration strategy that preserved existing credentials while enabling cross-subdomain authentication.
THE PROBLEM: SUBDOMAIN-LOCKED CREDENTIALS
WebAuthn credentials are bound to a Relying Party (RP) ID. When we first implemented biometrics, we set the RP ID to control.boottify.com — the subdomain where our dashboard lives. This worked fine until we needed authentication on the apex domain too.
The WebAuthn spec allows the RP ID to be set to the apex domain (a "registrable domain suffix") during credential creation. But once a credential is created with a specific RP ID, it cannot be changed. The browser will refuse to use a credential if the RP ID doesn't match exactly. This meant all existing biometric credentials were locked to the subdomain.
THE DUAL RP ID STRATEGY
We needed to:
- Register all new credentials with the apex domain
boottify.com - Continue verifying old credentials that were registered with
control.boottify.com - Eventually migrate users to new credentials organically
The solution was a dual RP ID configuration in our WebAuthn shared config:
// src/lib/auth/webauthn/shared.ts
export const rpConfig = {
// Primary RP ID for new registrations
rpID: process.env.WEBAUTHN_RP_ID || "boottify.com",
// Legacy RP ID for verifying old credentials
legacyRPID: "control.boottify.com",
// All valid RP IDs (used during authentication verification)
rpIDs: [
process.env.WEBAUTHN_RP_ID || "boottify.com",
"control.boottify.com",
],
// Allowed origins for WebAuthn ceremonies
origins: (process.env.WEBAUTHN_ORIGINS || "https://control.boottify.com,https://boottify.com")
.split(",")
.map((o) => o.trim()),
rpName: "Boottify",
};
Registration: Always Use Apex Domain
New credential registrations always use the apex domain RP ID. This ensures new credentials work across all subdomains:
// Registration options use primary RP ID only
const options = await generateRegistrationOptions({
rpName: rpConfig.rpName,
rpID: rpConfig.rpID, // "boottify.com"
userID: userId,
userName: userEmail,
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
Authentication: Try Both RP IDs
During authentication verification, we try both RP IDs. The credential stores which RP ID it was created with, so we know which one to verify against:
// Verification accepts both RP IDs
const verification = await verifyAuthenticationResponse({
response: authResponse,
expectedChallenge: challenge,
expectedOrigin: rpConfig.origins,
expectedRPID: rpConfig.rpIDs, // ["boottify.com", "control.boottify.com"]
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
},
});
The expectedRPID array allows the SimpleWebAuthn library to match against either RP ID during verification. Old credentials verify against the legacy RP ID; new credentials verify against the apex domain.
DEVICE FINGERPRINTING
Alongside the RP ID migration, we implemented device fingerprinting to give users visibility into which devices have registered credentials. Each device gets a unique fingerprint based on browser characteristics:
// src/lib/auth/device-fingerprint.ts
export function generateDeviceFingerprint(
userAgent: string,
language: string
): string {
const { browser, os } = parseUserAgent(userAgent);
const components = [browser, os, language].join("|");
return crypto.createHash("sha256").update(components).digest("hex");
}
The fingerprint is a SHA-256 hash of the browser name, operating system, and language preference. It's stable enough to identify the same device across sessions, but doesn't include volatile data like screen resolution or installed fonts.
SMART DEVICE NICKNAMES
Raw fingerprints aren't user-friendly. We parse the user agent to generate recognizable device names:
export function parseUserAgent(ua: string): {
browser: string;
browserVersion: string;
os: string;
osVersion: string;
} {
// Browser detection
let browser = "Unknown Browser";
let browserVersion = "";
if (ua.includes("Chrome") && !ua.includes("Edg")) {
browser = "Chrome";
browserVersion = ua.match(/Chrome/([d.]+)/)?.[1] || "";
} else if (ua.includes("Firefox")) {
browser = "Firefox";
browserVersion = ua.match(/Firefox/([d.]+)/)?.[1] || "";
} else if (ua.includes("Safari") && !ua.includes("Chrome")) {
browser = "Safari";
browserVersion = ua.match(/Version/([d.]+)/)?.[1] || "";
}
// ... Edge, Opera, etc.
// OS detection with version
let os = "Unknown OS";
let osVersion = "";
if (ua.includes("Windows NT")) {
os = "Windows";
osVersion = ua.match(/Windows NT ([d.]+)/)?.[1] || "";
} else if (ua.includes("Mac OS X")) {
os = "macOS";
osVersion = ua.match(/Mac OS X ([d_]+)/)?.[1]?.replace(/_/g, ".") || "";
}
// ... Linux, iOS, Android, etc.
return { browser, browserVersion, os, osVersion };
}
This enables smart nicknames in the UI: "Windows Hello" for Windows biometrics, "Touch ID (Mac)" for macOS, "Face ID (iPhone)" for iOS devices. Users instantly understand which credential belongs to which device.
DATABASE EXTENSIONS
We extended two database tables to support the new functionality:
user_devicestable — fingerprint-based device tracking with SHA-256 identifier, browser, OS, language, and last seen timestampauth_sessionsextended — newdeviceIdanddeviceTypecolumns linking sessions to their originating device- WebAuthn credentials — new
rpIdcolumn stores which RP ID the credential was created with, plusdeviceIdfor device association
The upsertUserDevice() function is called on every authentication route (sign-in, 2FA verification, WebAuthn authentication) to keep device records current.
PLATFORMAUTHGATE: REPLACING MOBILEONLY
Our original biometrics UI component, MobileOnly, only showed biometric options on mobile devices. This was wrong — Windows Hello and macOS Touch ID are desktop biometric authenticators. We replaced it with PlatformAuthGate:
// usePlatformAuth hook
export function usePlatformAuth() {
const [isAvailable, setIsAvailable] = useState(false);
useEffect(() => {
async function check() {
if (!window.PublicKeyCredential) return;
const available = await PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable();
setIsAvailable(available);
}
check();
}, []);
return { isAvailable };
}
This hook checks for platform authenticator availability regardless of device type. If the device has a biometric sensor (fingerprint reader, face camera, or Windows Hello TPM), the biometric options are shown.
DEVICE MANAGEMENT API
Users can view and manage their registered devices through the settings page:
GET /api/settings/devices— lists all devices with their credentials, last seen time, and smart nicknameDELETE /api/settings/devices— removes a device and revokes all associated WebAuthn credentials
This gives users full control over their authentication footprint. If a device is lost or stolen, they can immediately revoke all credentials associated with it.
THE MIGRATION PATH
Old credentials continue to work indefinitely — we don't force users to re-register. Over time, as users register new credentials (on new devices, or when prompted after a credential reset), those credentials automatically use the apex domain RP ID. The dual verification ensures a smooth transition with zero user friction.
The entire migration — dual RP ID config, device fingerprinting, database extensions, PlatformAuthGate, and device management API — was deployed with no user-visible changes. Existing biometric logins kept working. New registrations silently switched to the apex domain. That's how infrastructure migrations should work: invisible to the user, transformative under the hood.



