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, Edge | Supported. |
| Safari, Firefox | Not fully supported. Behaviour ranges from absent to partial across versions. |
| Consequence | A 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 redirectTreat 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.
| Method | Endpoint | Credential | Purpose |
|---|---|---|---|
GET | /.well-known/web-identity | none | Names the config URL. See the eTLD+1 trap. |
GET | /fedcm/config.json | none | The provider manifest: endpoints, login_url, branding. |
GET | /fedcm/accounts | session cookie | The signed-in accounts. 401 when signed out. |
POST | /fedcm/assertion | session cookie | Mints the ID token for one RP. |
GET | /fedcm/client-metadata | none | The RP’s privacy / terms links, shown in the browser’s dialog. |
POST | /fedcm/disconnect | session cookie | Forgets that this person connected to that RP. |
GET | /fedcm/login | optional | The 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", "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:
$ curl -si https://auth.civicgate.org/fedcm/config.json | head -1HTTP/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 -1HTTP/2 200Practical 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.
Condition 2 — the session cookie must be cross-site
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.
| Env | Default | Meaning |
|---|---|---|
AUTH_COOKIE_CROSS_SITE | on when the OIDC/FedCM provider is available | Writes 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):
| Cookie | HttpOnly | SameSite | Notes |
|---|---|---|---|
rw3iss_access | yes | None in cross-site mode, else Lax | The one FedCM reads. |
rw3iss_refresh | yes | always Strict | Nothing cross-site needs it, and it outlives the access token. |
rw3iss_csrf | no — JS reads it | same as access | Paired 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.
cookie_mode is honoured only for a first-party login
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-Siteis the check. Also a forbidden header name, and it separates the cases cleanly:www.example.com → auth.example.comissame-site; any attacker page iscross-site.- Absent header (older browser, non-browser client) falls back to an
exact
Originmatch againstCORS_ORIGINS. A literal*is not honoured there — that would turn the fallback into no check at all. - No
Originat 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-inon thecookie_modelogin response, andSet-Login: logged-outonPOST /auth/logout.GET /fedcm/login— thelogin_urlfrom the config document. Signed in, it emitsSet-Login: logged-in, callsnavigator.login.setStatus("logged-in")and thenIdentityProvider.close(). Signed out, it302s toOIDC_LOGIN_URLwith anext=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.jsonthe 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.
- Error shape. The platform’s
writeErroremits{"error":{"code","message","details"}}with codes likeUNAUTHORIZED. FedCM defines{"error":{"code","url"}}with OAuth2 codes (access_denied,invalid_request,unauthorized_client,server_error). The FedCM endpoints emit the spec’scodeand the platform’smessagein one object; the browser readscodeand ignores the rest, so both conventions hold. - No preflight, but CORS is still mandatory.
application/x-www-form-urlencodedis CORS-safelisted, so noOPTIONSfires for assertion or disconnect — but the response still needsAccess-Control-Allow-Origin: <exact RP origin>andAccess-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 a401is blocked by the browser and reaches the RP as an opaque network error instead of a readable one. noncemoved. Chrome ≤142 sent it as its own form field; Chrome 143 moved it insideparams, a percent-encoded JSON blob; Chrome 145 drops the flat form. Both are read,paramsfirst. Handling only one would silently drop the nonce during the transition — no error anywhere, just a token with no replay binding.
Verifying a deployment
# 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'Read next
- Sign in with {Provider} — FedCM with its required fallbacks, end to end.
- OIDC provider — the keys, registry and consent store FedCM reuses.
- Provider identity — the
methodsarray that tells an RP whetherfedcmis even available here. - Server source:
docs/FEDCM.md(the operator’s deep-dive),internal/api/handlers/fedcm_handler.go,internal/api/middleware/fedcm.go,internal/api/middleware/cookie.go.