Turn a Node.js app into a FedCM Identity Provider. FedCM
(Federated Credential Management, spec) is a browser-mediated
federated sign-in flow: the relying party calls navigator.credentials.get({ identity: ... }), the
browser fetches a small set of well-known endpoints from your IdP, renders its own account chooser,
and hands the RP an assertion token — no third-party cookies, no redirect dance. fedcm-server
implements that IdP endpoint surface as a framework-agnostic route map with thin adapters for
Express, Fastify, Hono, and raw node:http. You supply an adapter that answers "who is signed in?"
and "mint a token for this client"; the library handles the protocol shape and the browser-facing
security checks.
- Framework-agnostic core —
createFedCMRoutes(config)returns plain{ method, path, handler }routes over a small request/response contract, with adapters for Express, Fastify, Hono, andnode:http. - All FedCM IdP endpoints:
/.well-known/web-identity, IdP config, accounts, ID assertion, client metadata, disconnect. Sec-Fetch-Dest: webidentityvalidation on every credentialed endpoint, plus per-request CORS headers on the assertion and disconnect responses.- Login Status API helper —
Set-Login: logged-in|logged-outheader generation. - Safari/ITP popup fallback (opt-in, non-standard): a served client script plus a popup endpoint for browsers that do not implement FedCM.
- Dev server with an in-memory mock account store, strict TypeScript types throughout.
- ESM-only, zero runtime dependencies (framework packages are optional peer dependencies).
npm install fedcm-serverRequirements:
- Node.js >= 18 (tested on Node 20+ in CI)
- ESM only. The package ships
"type": "module"with no CommonJS build — useimport, notrequire(). express/fastify/honoare optional peer dependencies; the core and thenode:httpadapter need nothing.- HTTPS in production — browsers only run FedCM against secure origins (
localhostexcepted).
import express from "express";
import { createFedCMMiddleware, setLoginStatus } from "fedcm-server/express";
import type { FedCMAccountAdapter } from "fedcm-server";
// Registered out of band: which RP origins may use which client_id.
// The FedCM spec requires the IdP to enforce this — see "The adapter contract".
const CLIENT_ORIGINS: Record<string, readonly string[]> = {
"rp-web-app": ["https://app.example.com"],
};
const adapter: FedCMAccountAdapter = {
async getAccounts(cookies) {
const user = await lookupSession(cookies["session"]);
if (!user) {
return null; // -> 401, the browser treats the IdP as signed out
}
return [{
id: user.id,
name: user.fullName,
email: user.email,
given_name: user.firstName,
picture: user.avatarUrl,
approved_clients: user.approvedClientIds, // client_ids that already have consent
}];
},
async createAssertion({ accountId, clientId, origin, nonce }) {
// 1. Is this client_id allowed to be used from this origin? (Required — see below.)
if (!CLIENT_ORIGINS[clientId]?.includes(origin)) {
return { error: { code: "unauthorized_client" } };
}
// 2. Mint a token bound to this client (audience) and this nonce (replay).
const token = await mintIdToken({ sub: accountId, aud: clientId, nonce });
return { token };
},
async getClientMetadata(clientId) {
const client = await lookupClient(clientId);
return {
privacy_policy_url: client?.privacyPolicyUrl,
terms_of_service_url: client?.termsUrl,
};
},
async disconnect({ accountHint, clientId }) {
const user = await revokeGrant(accountHint, clientId);
return { accountId: user.id };
},
};
const app = express();
// Mount at the app root: the middleware owns absolute paths such as
// /.well-known/web-identity.
app.use(createFedCMMiddleware({ issuer: "https://idp.example.com", adapter, loginUrl: "/login" }));
// Tell the browser about login state on every auth transition (Login Status API):
// once it records "logged-out" it stops fetching your accounts endpoint until you
// send "logged-in" again.
app.post("/login", async (req, res) => {
await signIn(req, res);
setLoginStatus(res, "logged-in");
res.redirect("/");
});
app.post("/logout", async (req, res) => {
await signOut(req, res);
setLoginStatus(res, "logged-out");
res.redirect("/login");
});
app.listen(3000);Session cookies must be readable in the FedCM (cross-site) context: set them
SameSite=None; Secure; HttpOnly.
Paths are the defaults from src/core/defaults.ts and are individually overridable via config.
| Route | Method | Default path | Role in the flow |
|---|---|---|---|
wellKnown |
GET | /.well-known/web-identity |
Discovery. Returns { "provider_urls": ["<issuer><configPath>"] } so the browser can confirm the IdP sanctions this config URL. Path is fixed by the spec. |
config |
GET | /fedcm/config.json |
The IdP manifest: accounts_endpoint, client_metadata_endpoint, id_assertion_endpoint, disconnect_endpoint, login_url, plus branding / account_label / itp_support when configured. |
accounts |
GET | /fedcm/accounts |
Credentialed. Calls adapter.getAccounts(cookies) and returns { accounts: [...] }; null becomes 401 { error: "Not signed in" }, which the browser reads as signed out. |
assertion |
POST | /fedcm/assertion |
Credentialed, form-encoded (account_id, client_id, nonce, disclosure_text_shown, is_auto_selected). Calls adapter.createAssertion and returns 200 { token } or 403 { error: { code, url } }. |
clientMetadata |
GET | /fedcm/client-metadata?client_id=… |
Privacy policy / terms URLs the browser shows in its disclosure UI. |
disconnect |
POST | /fedcm/disconnect |
Form-encoded (account_hint, client_id). Calls adapter.disconnect and returns { account_id }. |
itpScript |
GET | /fedcm/connect.js |
Only when itpSupport.enabled — serves the client fallback script. |
itpPopup |
GET | /fedcm/popup |
Only when itpSupport.enabled — the popup authentication page. |
accounts, assertion, clientMetadata and disconnect reject any request without
Sec-Fetch-Dest: webidentity with 400 { error: "Missing required Sec-Fetch-Dest: webidentity header" }.
That header cannot be set by fetch()/XHR, so it is what keeps these endpoints reachable only from
the browser's FedCM machinery. assertion and disconnect additionally require an Origin header
and echo it back with Access-Control-Allow-Origin / Access-Control-Allow-Credentials: true.
type FedCMIssuer = string | ((req: FedCMRequest) => string);
interface FedCMServerConfig {
issuer: FedCMIssuer; // absolute base URL; builds the well-known provider URL
adapter: FedCMAccountAdapter; // your implementation (see below)
loginUrl?: string; // default "/login" — advertised as config.json login_url
branding?: FedCMBranding; // background_color, color, icons: [{ url, size }]
accountLabel?: string; // config.json account_label
itpSupport?: ITPSupportConfig; // opt-in Safari fallback (see below)
// configPath / accountsPath / assertionPath / clientMetadataPath / disconnectPath
// override the default paths listed in the table above.
}issuer may be a function when one server answers on several hostnames (localhost, a LAN IP, a
public hostname). It is resolved per request, so /.well-known/web-identity advertises a
provider_urls entry matching the origin the browser is actually on — a fixed string would
mismatch and the browser would reject the config URL. Validate the host against an allowlist;
never echo the Host header back unchecked.
const ALLOWED_HOSTS = new Set(["idp.example.com", "192.168.1.20:8443"]);
createFedCMRoutes({
issuer: (req) => {
const host = req.headers["host"];
return `https://${host && ALLOWED_HOSTS.has(host) ? host : "idp.example.com"}`;
},
adapter,
});FedCMAccountAdapter is the entire integration surface. Everything that depends on your identity
store, your client registry, and your token format lives here.
interface FedCMAccountAdapter {
getAccounts(cookies: Record<string, string>): Promise<readonly FedCMAccount[] | null>;
createAssertion(params: {
accountId: string; clientId: string; origin: string;
nonce?: string; params: Readonly<Record<string, unknown>>; rawParams?: string;
disclosureTextShown: boolean; isAutoSelected: boolean;
}): Promise<{ token: string } | { error: { code?: string; url?: string } }>;
getClientMetadata(
clientId: string,
): Promise<{ privacy_policy_url?: string; terms_of_service_url?: string }>;
disconnect(params: { accountHint: string; clientId: string }): Promise<{ accountId: string }>;
}Resolve the session from the request cookies (already parsed into a flat record by the adapter) and
return the signed-in accounts. Return null — not [] — when there is no session; the endpoint
answers 401 and the browser treats the IdP as logged out. Account fields are FedCM's
snake_case wire names:
interface FedCMAccount {
id: string; // stable account id, echoed back as account_id on assertion
name: string; // display name
email: string;
tel?: string;
username?: string;
given_name?: string;
picture?: string; // absolute avatar URL
approved_clients?: readonly string[]; // client_ids that already have consent (skips disclosure UI)
domain_hints?: readonly string[];
label_hints?: readonly string[];
login_hints?: readonly string[];
}Called with the account the user picked in the browser's chooser, the RP's client_id, the RP's
origin (taken from the request's Origin header), the RP-supplied nonce, whether the browser
showed disclosure text, and whether the credential was auto-selected. Return { token } (any string
your RPs can verify — typically a signed JWT) or { error: { code, url } }, which the endpoint
returns as 403.
Where the nonce comes from. Modern RPs pass a params object to navigator.credentials.get(),
which reaches the id_assertion_endpoint as a single form field holding serialized JSON, and the nonce
normally lives inside it — the top-level nonce field is being deprecated
(w3c-fedid/FedCM#616). nonce is therefore resolved
as params.nonce when that is a string, falling back to the legacy top-level field, then undefined.
Read params.nonce yourself only if you need to distinguish the two sources.
The blob itself is handed over as well:
params— the parsed object.{}when the field is absent, malformed, or not a JSON object; parsing never throws into your adapter. Everything in it is untrusted RP input — validate any field you act on, and expect extra keys (scope,audience, whatever the RP sent).rawParams— the field exactly as received, orundefined. Use it when a signature or audit record must cover the RP's original bytes rather than a re-serialization.
On the Safari/ITP popup path there is no params blob — that endpoint has its own query-parameter
contract — so adapters see params: {} and the nonce query parameter there.
Security: origin↔client validation and token binding are your responsibility. The FedCM spec requires the IdP to verify, inside the assertion endpoint, that the requesting
originis a registered origin for the givenclientId, and to bind the issued token to that client (audience) and to the requestnonce(replay protection). This library deliberately does not do that: it has no client registry and no signing key. It validatesSec-Fetch-Dest: webidentity, requires and echoesOrigin, and shapes responses — everything else iscreateAssertion's job. See the quickstart adapter above: aclientId -> allowed originstable, an earlyreturn { error: { code: "unauthorized_client" } }on mismatch, then a token carryingaud: clientIdand the requestnonce.Skipping the origin check lets any site that learns a
client_idobtain tokens for your users. Omittingaudlets a token minted for one RP be replayed at another; omittingnoncelets a captured token be replayed at the same RP.error.codemay be one of the spec's known codes (invalid_request,unauthorized_client,access_denied,temporarily_unavailable,server_error) or your own string;error.urlcan point at a human-readable explanation page.
getClientMetadata(clientId)— the RP's privacy policy and terms URLs for the browser's disclosure UI. Both fields optional; return{}for unknown clients. This endpoint is not credentialed with your session, so do not leak user data through it.disconnect({ accountHint, clientId })— revoke the grant for that account hint and client and return the affected{ accountId }, echoed to the browser asaccount_id. Afterwards the browser drops its record of the account↔RP connection.
The login location is not part of the adapter: it comes from FedCMServerConfig.loginUrl, which is
advertised as login_url and used by the ITP popup redirect.
import Fastify from "fastify";
import { fedcmPlugin, setLoginStatus } from "fedcm-server/fastify";
const app = Fastify();
await app.register(fedcmPlugin, { issuer: "https://idp.example.com", adapter });
await app.listen({ port: 3000 });
// setLoginStatus(reply, "logged-in") inside your auth handlersThe plugin registers an application/x-www-form-urlencoded content-type parser and every route in
the map, including the ITP routes when enabled.
import { Hono } from "hono";
import { createFedCMApp, setLoginStatus } from "fedcm-server/hono";
const app = new Hono();
app.route("/", createFedCMApp({ issuer: "https://idp.example.com", adapter }));
// setLoginStatus(c, "logged-out") inside your auth handlersfedcmMiddleware(config) is an alias for createFedCMApp(config). It registers every route in the
map, including the ITP routes when enabled.
import { createServer } from "node:http";
import { createFedCMHandler, setLoginStatus } from "fedcm-server/node";
const handleFedCM = createFedCMHandler({ issuer: "https://idp.example.com", adapter });
createServer(async (req, res) => {
// Returns true when the request matched a FedCM route and was answered.
if (await handleFedCM(req, res)) {
return;
}
// ... your own routing, calling setLoginStatus(res, "logged-in") on auth events
}).listen(3000);No try/catch is needed around handleFedCM: like the other three adapters, it answers a throwing
adapter with a generic 500 { "error": "Internal server error" } and always ends the response. The
underlying error message is never sent to the browser — log it inside your own adapter if you need it.
For any other framework, build the routes yourself: createFedCMRoutes(config) returns
{ wellKnown, config, accounts, assertion, clientMetadata, disconnect, itpScript?, itpPopup? },
each a { method, path, handler }. A handler takes
{ method, path, headers, cookies, body?, query? } (headers lower-cased, cookies and form bodies
pre-parsed into flat records) and resolves to { status, headers, body }. Apply headers verbatim and
switch on the body type: the protocol endpoints return an object to serialise as JSON, the ITP script
and popup routes return a string to send as-is.
Safari does not implement FedCM and blocks third-party cookies via ITP, so the native flow cannot run there. Opt into a popup-based fallback:
createFedCMRoutes({
issuer: "https://idp.example.com",
adapter,
loginUrl: "/login",
itpSupport: {
enabled: true,
allowedOrigins: ["https://app.example.com"], // exact RP origins, no wildcards
scriptPath: "/fedcm/connect.js", // optional, this is the default
popupPath: "/fedcm/popup", // optional, this is the default
},
});This adds two routes. GET <scriptPath> serves a dependency-free client script (with the configured
popupPath baked in) that defines window.FedCMConnect. GET <popupPath>?client_id=…&origin=…&nonce=…
validates origin against allowedOrigins, calls adapter.getAccounts with the popup's
first-party cookies, and then either calls adapter.createAssertion and renders a page that
postMessages { type: "fedcm-credential", token } to window.opener before closing itself, or
redirects to loginUrl with a redirect parameter pointing back at popupPath so the flow resumes
after login. All four adapters (Express, Fastify, Hono, node:http) mount both routes automatically
whenever itpSupport.enabled is set, and send them with their own content types — JavaScript for the
script, HTML for the popup page.
The popup page is a top-level navigation only: it is served with X-Frame-Options: DENY and
Content-Security-Policy: frame-ancestors 'none', and a request that arrives with a Sec-Fetch-Dest
other than document is answered 403 before the adapter is called at all.
On the RP page:
<script src="https://idp.example.com/fedcm/connect.js"></script>
<script>
// Uses native FedCM when available, otherwise the popup.
const result = await window.FedCMConnect.get({
configURL: "https://idp.example.com/fedcm/config.json",
clientId: "rp-web-app",
nonce: crypto.randomUUID(),
});
// result: { token, isAutoSelected?, source: "fedcm" | "popup" }
await fetch("/session", { method: "POST", body: result.token });
</script>window.FedCMConnect also exposes prompt(config) (renders a built-in sign-in sheet and invokes
config.callback with the result, or config.onError with the failure), cancel(), and
isSupported(). The result and config shapes are published as types —
import type { FedCMConnectConfig, FedCMConnectResult } from "fedcm-server" — along with
isFedCMSupported() for your own feature detection.
Caveats — read these before enabling:
- This popup flow is a non-standard extension, not part of the FedCM spec. The
itp_supportkey ({ script_url, popup_url }) added toconfig.jsonwhenitpSupportis enabled is this library's own convention; browsers ignore it. Only clients that know aboutfedcm-servercan use this path. - The
BroadcastChannelCOOP fallback only works when the RP and IdP are same-origin. Token delivery is primarilywindow.opener.postMessage. If aCross-Origin-Opener-Policyheader on the popup response severs the opener reference, the popup falls back to aBroadcastChannelmessage — butBroadcastChannelnever crosses origins, and the popup runs on the IdP origin. A cross-origin RP therefore receives nothing and fails with a popup-closed or timeout error (which carries a hint saying exactly this). Do not send COOP headers that severwindow.openeron the popup response. - The popup issues an assertion for the first account returned by
getAccounts, withdisclosureTextShown: falseandisAutoSelected: false. There is no account chooser on this path, so it suits single-session IdPs. YourcreateAssertionorigin↔client check still runs — and still must.
The same client script also ships prebuilt inside the npm tarball, so jsDelivr and unpkg serve it to
RPs that cannot add an IdP-hosted <script> tag. Pin the version:
<script src="https://cdn.jsdelivr.net/npm/fedcm-server@0.1.2/dist/browser/fedcm-connect.min.js"></script>
<script>
const result = await window.FedCMConnect.get({
configURL: "https://idp.example.com/fedcm/config.json",
clientId: "rp-web-app",
nonce: crypto.randomUUID(),
popupUrl: "/auth/fedcm-popup", // only when the IdP's popupPath is not the default
});
</script>The CDN bundle is generated with the default popup path /fedcm/popup baked in. If your IdP
configured a different popupPath, pass popupUrl in the call config — it is resolved against the
configURL origin — otherwise the popup opens a path the IdP does not serve. Serving the script from
the IdP via createScriptHandler, which bakes the configured path in for you, stays the recommended
default; the CDN copy is for RPs that cannot do that. dist/browser/fedcm-connect.js is the same
bundle unminified.
Because this script acquires credentials, add Subresource Integrity: version-pinned jsDelivr URLs are
immutable, so copy the sha384-… hash jsDelivr publishes for the pinned file (the SRI option on
its jsDelivr page, or the hash field from
https://data.jsdelivr.com/v1/packages/npm/fedcm-server@0.1.2) and load the tag as
<script src="…" integrity="sha384-…" crossorigin="anonymous"></script>.
A throwaway IdP for local testing, built on the node:http adapter and an in-memory mock store:
import { createDevServer } from "fedcm-server/dev";
const server = createDevServer({
port: 3000, // default 3000
hostname: "localhost", // default "localhost"
users: [{ id: "1", name: "Test User", email: "test@example.com", password: "password" }],
});
await server.start(); // await server.stop() to shut downIt serves all six FedCM endpoints plus / (status page), GET|POST /login, and POST /logout,
sets Set-Login on login and logout, and logs every request. The default credentials are
test@example.com / password. Tokens are unsigned base64url JSON — useful for inspecting the
flow, never for anything else.
/login honours a ?redirect= parameter — the ITP popup sends logged-out users to
loginUrl?redirect=<popup path> — and returns there after a successful sign-in. Only same-origin
relative paths are followed: the value must start with /, must not start with // and must not
contain :// or a backslash; anything else falls back to /.
The same subpath also exports the pieces the dev server is built from, so you can assemble your
own test harness (custom HTTPS, extra routes, itpSupport) on top of the shipped mocks:
import { createMockStore, renderLoginPage } from "fedcm-server/dev";
import type { MockStore, MockUser, LoginPageConfig } from "fedcm-server/dev";
const store = createMockStore({ users }); // an in-memory FedCMAccountAdapter
const sessionId = store.login(email, password); // plus login()/logout()
const html = renderLoginPage({ error: false, redirectTo: "/fedcm/popup?client_id=…" });examples/betterauth is a complete IdP backed by
BetterAuth and SQLite, with a built-in RP test page, HTTPS dev mode
via mkcert, browser capability detection, and the ITP popup fallback wired up. See its
README for setup and Chrome DevTools/flag tips.
Chrome and Edge 117+ implement FedCM natively; Safari does not, and is covered only by the opt-in
popup fallback described above. For everything else, check the
MDN compatibility table —
isFedCMSupported() (exported from fedcm-server) is the runtime check.
- Serve over HTTPS in production. Browsers restrict FedCM to secure origins, and cross-site
session cookies need
SameSite=None; Secure. Sec-Fetch-Dest: webidentityis enforced on the accounts, assertion, client-metadata, and disconnect endpoints. The ITP popup endpoint is a top-level navigation and is instead gated on theallowedOriginslist.- Validate
originagainstclientIdand bind tokens toaud+nonceinsidecreateAssertion— see the security note above. The library cannot do this for you. - Never return more than the browser needs from
getAccountsandgetClientMetadata. - Report vulnerabilities privately per SECURITY.md — please do not open public issues for them.
Bug reports and pull requests are welcome. See CONTRIBUTING.md for the dev setup, project layout, and test expectations; notable changes are recorded in CHANGELOG.md.
MIT © HackPad Labs. See LICENSE.