Skip to content
rw3iss Auth

FedCM

FedCM (Federated Credential Management) lets a site offer “Sign in with {Provider}” where the browser draws the account chooser, instead of a popup window and third-party cookies. The person taps one row in a dialog the browser renders; the relying party (RP) receives a signed ID token.

What it is, and what it is not

It is a browser API. navigator.credentials.get({ identity: … }). The browser is the mediator: it fetches this server’s endpoints, draws the UI, and hands the result to the calling page.

This auth-server is the identity provider. Not a middleman, not a hosted service, no third party in the path. The endpoints below are served by your own deployment; nobody else is involved in the exchange.

It is not a second identity system. It reuses the existing OIDC provider wholesale — the same signing key, the same JWKS, the same oauth_clients registry, the same oauth_consents table. FedCM is a new way for a browser to ask for identity. There is nothing extra to register.

Why it exists here: third-party cookies are going away. Every previous way for a page on example.org to detect an existing session with this server — a hidden iframe, a third-party cookie, a silent probe — is already partitioned or on its way out. FedCM is the replacement, and it is a better one: the IdP is never told which site is asking until the person has chosen.

Read this before you build on it

FedCM is genuinely useful and genuinely limited. The limits are not bugs and are not fixable at this end.

Status
Chrome, EdgeSupported.
Safari, FirefoxNot fully supported. Behaviour ranges from absent to partial across versions.
ConsequenceA popup/redirect fallback is always required. Never ship FedCM as the only path.

Feature-detect and fall back — the sequence is written out in Sign in with {Provider}:

if ('IdentityCredential' in window) { /* try FedCM */ }
// then, always: OIDC popup or redirect

Treat a FedCM failure as “not available here”, not as an error worth showing the user. Falling through to the OIDC path is the correct handling of nearly every rejection it can produce.

The endpoints

All at the root of the issuer origin, never under API_PREFIX. The config URL is what an RP names, and every other endpoint resolves relative to it — so a prefixed path is somewhere no browser looks.

MethodEndpointCredentialPurpose
GET/.well-known/web-identitynoneNames the config URL. See the eTLD+1 trap.
GET/fedcm/config.jsonnoneThe provider manifest: endpoints, login_url, branding.
GET/fedcm/accountssession cookieThe signed-in accounts. 401 when signed out.
POST/fedcm/assertionsession cookieMints the ID token for one RP.
GET/fedcm/client-metadatanoneThe RP’s privacy / terms links, shown in the browser’s dialog.
POST/fedcm/disconnectsession cookieForgets that this person connected to that RP.
GET/fedcm/loginoptionalThe login_url page. Sets the browser’s login-status bit.

GET /.well-known/web-identity

{ "provider_urls": ["https://auth.civicgate.org/fedcm/config.json"] }

GET /fedcm/config.json

Cache-Control: public, max-age=600. Endpoint paths are relative — they resolve against the config document’s own URL.

{
"accounts_endpoint": "/fedcm/accounts",
"id_assertion_endpoint": "/fedcm/assertion",
"client_metadata_endpoint": "/fedcm/client-metadata",
"disconnect_endpoint": "/fedcm/disconnect",
"login_url": "https://auth.civicgate.org/fedcm/login",
"branding": {
"name": "CivicGate",
"background_color": "#0f1115",
"color": "#e6e6ea",
"icons": [{ "url": "https://www.civicgate.org/icon-192.png", "size": 192 }]
}
}

branding comes from the same env as provider identity. Chrome requires the icon to be ≥25px (passive mode) / ≥40px (button mode).

GET /fedcm/accounts

Cache-Control: no-store. Zero or one account — this is a single-account server.

{
"accounts": [
{
"id": "9c1f…",
"name": "Jane Doe",
"email": "[email protected]",
"username": "jane",
"given_name": "Jane",
"picture": "https://…",
"approved_clients": ["rp-demo"],
"login_hints": []
}
]
}

401 when signed out. That is correct, not a fault — it is how the browser distinguishes “signed out” from “signed in with no accounts”.

approved_clients is read from oauth_consents — the same rows the OIDC consent screen writes — and is what tells the browser whether to show the “you are about to share your name and email with X” disclosure. When the consent lookup fails, the server reports nothing approved: the cost is one extra prompt, where claiming an approval it could not verify would silently skip a disclosure.

POST /fedcm/assertion

Form-encoded. Accepts client_id, account_id, fields (comma-separated), params (a percent-encoded JSON blob that may carry nonce), nonce (flat, legacy), disclosure_text_shown. Reads the Origin header.

{ "token": "<RS256 id_token>" }

An ordinary OIDC ID token: kid in the header, verifiable against /.well-known/jwks.json, iss = the issuer, aud = your client_id. Consent is recorded only when the browser reports disclosure_text_shown=true — i.e. the person was actually asked.

What the token carries. The browser’s fields parameter is mapped onto this server’s OIDC scopes, so the existing scope-based claim filtering stays the single place that decides token contents — FedCM gets no parallel notion of “what may be shared”. An absent fields means the browser used its default disclosure (name, email, picture) and maps to openid profile email. The result is then intersected with the client’s allowed_scopes, so a client cannot widen its own reach by asking the browser for more.

POST /fedcm/disconnect

Deletes the consent row → {"account_id": "<uuid>"}. It does not end the session: disconnecting from one site must not sign the person out of everything.

GET /fedcm/client-metadata

Uncredentialed by design; needs a client_id query parameter. Privacy/terms URLs are derived from the client’s primary registered origin; icons comes from its logo_url.

{
"privacy_policy_url": "https://rp.example/privacy",
"terms_of_service_url": "https://rp.example/terms",
"icons": [{ "url": "https://rp.example/logo.png" }]
}

Condition 1 — Sec-Fetch-Dest: webidentity

Every FedCM endpoint except /fedcm/login requires the header, and it is the access control, not decoration.

Sec-Fetch-Dest is a forbidden header name: page script cannot set it, and fetch() drops any attempt. Its presence is therefore the browser asserting that it made the request as part of a FedCM flow. Without the check, GET /fedcm/accounts would be a credentialed read of a signed-in person’s name and email, available to any cross-site page that can get the cookie attached.

Verified live against the running server:

Terminal window
$ curl -si https://auth.civicgate.org/fedcm/config.json | head -1
HTTP/2 403
{"error":{"code":"FORBIDDEN","details":null,
"message":"This endpoint is callable only by the browser's FedCM implementation"}}
$ curl -si -H 'Sec-Fetch-Dest: webidentity' \
https://auth.civicgate.org/fedcm/config.json | head -1
HTTP/2 200

Practical consequence: curl needs the header, or everything answers 403. That 403 is the single most common “FedCM is broken” report and it is the system working.

/fedcm/login is exempt because the browser opens it as a top-level navigation, where Sec-Fetch-Dest is document.

The accounts call happens while the person is on the relying party’s page. That makes it a cross-site request, and a SameSite=Lax cookie is simply not attached — so the endpoint sees an anonymous request and honestly answers “no accounts”, with a perfectly valid session sitting right there and nothing in any log to explain it.

The same applies one step earlier: a login POST issued cross-origin (www.example.com → auth.example.com) cannot even store a Lax cookie. The browser rejects the Set-Cookie outright.

EnvDefaultMeaning
AUTH_COOKIE_CROSS_SITEon when the OIDC/FedCM provider is availableWrites the access + CSRF cookies SameSite=None; Secure. FedCM requires this.
AUTH_COOKIE_DOMAIN(unset — host-only)Widens the cookie to a parent domain. Leave unset for FedCM.

SameSite=None without Secure is rejected by every current browser, so cross-site mode forces Secure on regardless of ENVIRONMENT.

The cookies written by POST /auth/login with cookie_mode: true (the token pair is still returned in the body — the two are not exclusive):

CookieHttpOnlySameSiteNotes
rw3iss_accessyesNone in cross-site mode, else LaxThe one FedCM reads.
rw3iss_refreshyesalways StrictNothing cross-site needs it, and it outlives the access token.
rw3iss_csrfno — JS reads itsame as accessPaired with the X-CSRF-Token header.

CORS_ORIGINS must list the login UI’s origin exactly. A wildcard * cannot carry credentials, so the cookie never arrives.

SameSite=Lax was doing security work that None gives up, and it has to be replaced explicitly.

POST /auth/login is unauthenticated and carries no CSRF token, and a cross-site fetch with a CORS-safelisted content type triggers no preflight. So an attacker’s page can POST its own credentials with cookie_mode: true and credentials: "include". It cannot read the reply — CORS stops that — but it does not need to: the Set-Cookie lands anyway, and the victim’s browser is now holding a live session for the attacker’s account. The next FedCM sign-in anywhere then offers, or silently re-authenticates, the wrong person. That is login CSRF, and SameSite=Lax used to block it for free.

So cookies are written only when the request is first-party:

  • Sec-Fetch-Site is the check. Also a forbidden header name, and it separates the cases cleanly: www.example.com → auth.example.com is same-site; any attacker page is cross-site.
  • Absent header (older browser, non-browser client) falls back to an exact Origin match against CORS_ORIGINS. A literal * is not honoured there — that would turn the fallback into no check at all.
  • No Origin at all is a non-browser caller (curl, a server). It carries no ambient cookies to fixate, so it is allowed.

A refused request still logs in and still returns its tokens; only the cookies are withheld, and the refusal is logged at WARN with the offending Sec-Fetch-Site / Origin. Failing the whole login would break a bearer client that merely set the flag hopefully.

This is why the IdP must be auth.<domain> and never www.<domain>. The www origin deliberately has no session cookie — that is the assumption a shared page-cache policy depends on. Adding one there would silently make cached HTML visitor-dependent.

The login-status bit

The browser keeps a tri-state per IdP origin: unknown → logged-in → logged-out. While it reads logged-out the browser does not call the accounts endpoint at all — the FedCM request fails immediately. An IdP that never reports its status looks permanently signed out no matter how valid the cookie is.

Two mechanisms, both wired:

  • Set-Login: logged-in on the cookie_mode login response, and Set-Login: logged-out on POST /auth/logout.
  • GET /fedcm/login — the login_url from the config document. Signed in, it emits Set-Login: logged-in, calls navigator.login.setStatus("logged-in") and then IdentityProvider.close(). Signed out, it 302s to OIDC_LOGIN_URL with a next= that returns there.

Why that page has to exist on the IdP origin. Set-Login is honoured on top-level navigations and same-origin subresource requests. A login POST from www to auth is cross-origin, so the header may be ignored — and navigator.login.setStatus() only counts when executed on a page of the IdP origin. /fedcm/login is that page. The header on the login response is belt-and-braces, not the mechanism.

The well-known file must be served from the registrable domain

The single easiest thing to get wrong, and it fails with no useful diagnostic.

The browser does not fetch /.well-known/web-identity from the config URL’s origin. It fetches it from the registrable domain (eTLD+1) of that origin. With a config at

https://auth.civicgate.org/fedcm/config.json

the browser requests

https://civicgate.org/.well-known/web-identity ← the APEX, not auth.

which the auth server does not serve — that host is the web app’s vhost. A redirect does not help: the browser fetches the well-known with redirect mode error.

Deployment action. The apex vhost (or the web app itself) must serve, at https://<registrable-domain>/.well-known/web-identity, with Content-Type: application/json:

{ "provider_urls": ["https://auth.<registrable-domain>/fedcm/config.json"] }

The route stays registered on auth-server because it is correct for any deployment where the IdP is itself the registrable domain — localhost in development, a single-host production install — and because a wrong-but-present file is easier to diagnose than a missing one.

The check is skipped entirely when the RP is same-site with the IdP. www.civicgate.org and auth.civicgate.org share an eTLD+1, so a deployment’s own pages federate without the apex file existing at all. It matters only for genuinely third-party relying parties — which is the point of building this.

Registering a relying party

There is no FedCM registry. An RP is an OIDC client, created once with POST /api/v1/admin/oauth/clients. Two client lists would eventually disagree about who may ask for identity.

An RP is identified by its Origin header, which the browser sets and page script cannot forge. That origin is matched against the origins of the client’s registered redirect_uris and post_logout_uris:

{
"client_id": "rp-demo",
"name": "Demo RP",
// FedCM never redirects — but registering the origin here is what
// authorises it, and it is a field every OIDC client fills in anyway.
"redirect_uris": ["https://rp.example/auth/callback"],
"allowed_scopes": ["openid", "profile", "email"]
}

Matching is exact on scheme + host + port, with the default port normalised away (a browser’s Origin never carries :443; a registered redirect URI reasonably might). No wildcards, no prefix matching — a prefix-tolerant compare would also accept https://rp.example.attacker.net.

The relying party’s call

const cred = await navigator.credentials.get({
identity: {
providers: [{
configURL: 'https://auth.civicgate.org/fedcm/config.json',
clientId: 'rp-demo',
nonce: crypto.randomUUID(),
}],
},
});
// cred.token is an RS256 ID token. Verify it against
// https://auth.civicgate.org/.well-known/jwks.json
// (iss = the issuer, aud = your clientId, nonce = the one you sent).

Verify server-side. A token the RP’s own JavaScript “checked” proves nothing.

Spec-vs-server differences worth knowing

Three, each resolved deliberately.

  1. Error shape. The platform’s writeError emits {"error":{"code","message","details"}} with codes like UNAUTHORIZED. FedCM defines {"error":{"code","url"}} with OAuth2 codes (access_denied, invalid_request, unauthorized_client, server_error). The FedCM endpoints emit the spec’s code and the platform’s message in one object; the browser reads code and ignores the rest, so both conventions hold.
  2. No preflight, but CORS is still mandatory. application/x-www-form-urlencoded is CORS-safelisted, so no OPTIONS fires for assertion or disconnect — but the response still needs Access-Control-Allow-Origin: <exact RP origin> and Access-Control-Allow-Credentials: true, because the body crosses into the RP’s JavaScript. * is invalid with credentials. The headers are attached before any session check can fail; otherwise a 401 is blocked by the browser and reaches the RP as an opaque network error instead of a readable one.
  3. nonce moved. Chrome ≤142 sent it as its own form field; Chrome 143 moved it inside params, a percent-encoded JSON blob; Chrome 145 drops the flat form. Both are read, params first. Handling only one would silently drop the nonce during the transition — no error anywhere, just a token with no replay binding.

Verifying a deployment

Terminal window
# 1. The config document (needs the header, or 403).
curl -s -H 'Sec-Fetch-Dest: webidentity' \
https://auth.civicgate.org/fedcm/config.json | jq
# 2. The well-known — on the APEX, not on auth.
curl -s -H 'Sec-Fetch-Dest: webidentity' \
https://civicgate.org/.well-known/web-identity | jq
# 3. Signed out, accounts answers 401. That is correct: it is how the
# browser tells "signed out" from "signed in with no accounts".
curl -si -H 'Sec-Fetch-Dest: webidentity' \
https://auth.civicgate.org/fedcm/accounts | head -1
# 4. The cookie is actually cross-site capable.
# Look for SameSite=None; Secure — and for Set-Login.
curl -si -X POST https://auth.civicgate.org/api/v1/auth/login \
-H 'Content-Type: application/json' -H 'Origin: https://www.civicgate.org' \
-d '{"email":"…","password":"…","app_code":"civicgate","cookie_mode":true}' \
| grep -i 'set-cookie\|set-login'