1. Introduction
This section is non-normative.
In Federated Credential Management API, the user agent mediates sign-in between an RP and an IDP, but the IDP still issues an identity assertion for a specific RP at its identity assertion endpoint. That request tells the IDP which RP the user is signing in to (the IdP Tracking Problem).
This specification lets an IDP split itself in two, following the issuer-holder-verifier model of Verifiable Credentials:
-
The IDP becomes an issuer. Instead of an identity assertion for a specific RP, it issues a selectively disclosable credential (an SD-JWT Selective Disclosure for JWTs (SD-JWT)) bound to a key that the user agent generates. The issuer doesn’t learn who the RP is.
-
The user agent becomes the holder. It discloses only the claims that the RP asked for and that the user agreed to share, and binds the result to the RP and to a challenge from it (an SD-JWT+KB). Then it throws the key away.
-
The RP becomes the verifier. It checks the issuer’s signature, the disclosures, and the key binding.
An IDP opts in by using a VC issuance endpoint instead of an identity assertion endpoint in its config file:
An RP opts in by choosing the config URL of such a config file. Nothing else in its request
changes, except that it passes a nonce for the key binding:
const { token} = await navigator. credentials. get({ identity: { providers: [{ configURL: "https://issuer.example/vc.json" , fields: [ "email" ], nonce: "5678" }] } });
The flow is the same as in Federated Credential Management API until the user selects an account. Then:
-
The user agent generates an ephemeral key pair, the holder key.
-
The user agent sends the selected account’s ID and the public holder key to the VC issuance endpoint, with the user’s cookies but without anything that identifies the RP.
-
The issuer returns an SD-JWT bound to the public holder key, with the user’s claims as selective disclosures.
-
The user agent keeps only the disclosures that the RP asked for and the user agreed to share, and signs a key binding JWT with the RP’s origin as its audience and the RP’s nonce. Then it discards the private holder key.
Note: This specification is meant to compose with other FedCM extensions. For example, with IdP Registration API, an RP could accept a delegated credential from any registered issuer, while keeping that issuer from learning who the RP is.
Should issuers be able to issue delegated credentials ahead of time (the "push model"), so that the user agent can present them without contacting the issuer during sign-in?
This specification is written as a set of monkey patches to Federated Credential Management API, shown like this:
2. Issuers
2.1. The config file
partial dictionary IdentityProviderAPIConfig {USVString vc_issuance_endpoint ; };
vc_issuance_endpoint, of type USVString-
The URL of the issuer’s VC issuance endpoint, relative to the config file.
The vc_issuance_endpoint and the
id_assertion_endpoint are mutually exclusive: a config file has
one or the other. A config file that has a vc_issuance_endpoint
is a delegated config. If a config file has both, the
vc_issuance_endpoint is used, and the
id_assertion_endpoint is ignored.
Note: An RP chooses whether it gets an identity assertion or a delegated credential by choosing
a config URL. An IDP that supports both publishes two config files, for example
config.json and vc.json. Both can pass the well-known file check if the
well-known file lists the accounts_endpoint and
login_url that they share, rather than a single
provider_urls entry.
required keyword from the
id_assertion_endpoint member of IdentityProviderAPIConfig.
In fetch the config file, after the step that converts the response to an
IdentityProviderAPIConfig stored in config, add the following step:
-
If neither config["
id_assertion_endpoint"] nor config["vc_issuance_endpoint"] exists, set config to failure.
2.2. The VC issuance endpoint
The VC issuance endpoint is like the identity assertion endpoint, except that it
doesn’t learn who the RP is. The user agent sends it a POST request, with the user’s cookies,
with an opaque Origin, and without a referrer. The request body is
application/x-www-form-urlencoded with the following parameters, and nothing else:
account_id-
The
idof the account the user selected, as for the identity assertion endpoint. holder_key-
The public holder key, serialized as a JSON Web Key JSON Web Key (JWK).
POST /issue HTTP / 1.1 Host : issuer.example Origin : null Accept : application/json Content-Type : application/x-www-form-urlencoded Sec-Fetch-Dest : webidentity Cookie : 0x23223 account_id = 123 & holder_key = %7B%22kty%22%3A%22EC%22%2C%22crv%22%3A%22P-256%22%2C%22x%22%3A%22...%22%2C%22y%22%3A%22...%22%7D
Note: Unlike the request to the identity assertion endpoint, this request doesn’t include
client_id, params, fields, disclosure_text_shown, disclosure_shown_for, or
is_auto_selected, and its Origin is opaque, because any of those could reveal the RP.
The endpoint responds as the identity assertion endpoint does, with an
IdentityAssertionResponse. Its token is a delegated credential. The issuer can also use continue_on to show its own
UI first, as with the identity assertion endpoint, or error.
A delegated credential is an SD-JWT Selective Disclosure for JWTs (SD-JWT), serialized as a string in the
compact form <Issuer-signed JWT>~<Disclosure 1>~...~<Disclosure N>~, such that:
-
The Issuer-signed JWT has an
issclaim identifying the issuer, and SHOULD haveiatandexpclaims. -
The Issuer-signed JWT has a
cnfclaim Proof-of-Possession Key Semantics for JSON Web Tokens (JWTs) whosejwkmember is theholder_keyfrom the request. -
The Issuer-signed JWT doesn’t have
audornonceclaims. The holder adds those in the key binding JWT (§ 4.3 Presenting a delegated credential). -
Each of the user’s claims is a selectively disclosable top-level claim, rather than a claim in the clear, named with the same taxonomy that RPs use to request them: the field names in FedCM’s list of recognized fields (
"name","email","tel","username", and"picture"). Each claim’s value has the same meaning as theIdentityProviderAccountmember of the same name.
{ "iss" : "https://issuer.example" , "iat" : 1760295600 , "exp" : 1760299200 , "cnf" : { "jwk" : { "kty" : "EC" , "crv" : "P-256" , "x" : "..." , "y" : "..." } }, "_sd" : [ "6phoD5MBMETkuf5BYia7JD01dubqJSwGuz-o_9M206E" , "..." ], "_sd_alg" : "sha-256" }
And its disclosures, before base64url encoding:
[ "6403945e2c0a34a4" , "name" , "Alice Doe" ] [ "f3e080eb88b5f735" , "email" , "alice@example.com" ]
Note: Because the issuer names its disclosures with the same field names that RPs request, the user agent doesn’t need to map between taxonomies: it discloses a claim if its name is one of the requested fields.
How are nested claims and array elements handled? How is the taxonomy extended beyond the list of recognized fields?
How does a verifier find the issuer’s public keys? Options include the jku header,
the jwks_uri of the issuer’s metadata, or a well-known location keyed by iss.
Claims that the issuer puts in the clear, such as a stable sub, are revealed to every
RP. See § 5 Privacy considerations.
3. Verifiers
3.1. Requesting a delegated credential
An RP requests a delegated credential by passing the config URL of a delegated config. It doesn’t pass a new parameter to choose the credential’s format.
partial dictionary IdentityProviderRequestOptions {USVString nonce ; };
nonce, of type USVString-
A challenge from the RP, which the holder puts in the key binding JWT so that the RP can check that the presentation is fresh. It is required when the
configURLpoints to a delegated config, and is never sent to the IDP.
Note: In Federated Credential Management API, an RP passes its nonce in params, which
is sent to the identity assertion endpoint. For a delegated config,
params is never sent to the IDP, because it could identify
the RP. So the nonce needs its own member, which only the holder reads.
required keyword from the clientId member of
IdentityProviderConfig. Every algorithm in Federated Credential Management API that reads
clientId MUST first check that the member exists, and treat its
absence as failure, unless the provider’s config file is a delegated config.
Note: An RP doesn’t need to register with an issuer to get a delegated credential,
because the issuer never learns who the RP is. A clientId that
the RP passes is never sent to the issuer.
3.2. Verifying a delegated credential
This section is non-normative.
The RP receives the delegated credential in token, as a string
in the compact form <Issuer-signed JWT>~<Disclosure 1>~...~<Disclosure M>~<KB-JWT> Selective Disclosure for JWTs (SD-JWT).
To verify it, the RP:
-
Verifies the signature of the Issuer-signed JWT with the issuer’s public key, checks that
issis the issuer it expects (for example, by comparing it withconfigURL), and checksexp. -
For each disclosure, checks that its digest is in the Issuer-signed JWT’s
_sd. -
Verifies the signature of the KB-JWT with the key in the Issuer-signed JWT’s
cnf.jwk. -
Checks that the KB-JWT’s
audis its own origin, that itsnonceis thenonceit passed, that itsiatis recent, and that itssd_hashmatches the rest of the presentation. -
Accepts the disclosed claims.
4. The user agent as a holder
This section defines how the user agent acts as the holder: it generates a holder key, fetches a delegated credential from the VC issuance endpoint, and presents it to the RP.
4.1. Holder keys
A holder key is an ECDSA key pair on the P-256 curve, used with the ES256 algorithm,
that the user agent generates for a single sign-in. The user agent MUST generate a new
holder key for each request to a VC issuance endpoint, MUST NOT reveal its private key to
anyone, and MUST discard its private key as soon as it has signed the key binding JWT.
Note: Because each holder key is new, the public key that the issuer sees can’t be used to link sign-ins together, or to the RP that receives the presentation.
Should user agents support other algorithms? Should the issuer be able to say which ones it accepts?
4.2. Fetching a delegated credential
IdentityProviderRequestOptions provider, an
IdentityProviderAPIConfig config, and a globalObject, run the following steps. This returns
a tuple of (a string token, a string account ID), or failure.
-
Let issuanceUrl be the result of computing the manifest URL given provider, config["
vc_issuance_endpoint"], and globalObject. If issuanceUrl is failure, return failure. -
Let holderKey be a new holder key.
-
Let list be « ("
account_id", accountId), ("holder_key", the JSON Web Key JSON Web Key (JWK) serialization of holderKey’s public key) ». -
Let requestBody be the result of running the urlencoded serializer with list.
-
Let request be a new request as follows:
- URL
-
issuanceUrl
- method
-
POST - body
-
the UTF-8 encoding of requestBody
- redirect mode
-
"
error" - client
-
null
- service-workers mode
-
"
none" - destination
-
"
webidentity" - origin
-
a new opaque origin
- header list
-
a list containing a header named
Acceptwith valueapplication/json, and a header namedContent-Typewith valueapplication/x-www-form-urlencoded - referrer policy
-
"
no-referrer" - credentials mode
-
"
include" - mode
-
"
no-cors"
-
Let sdJwt be null.
-
Fetch request with request and globalObject, and with processResponseConsumeBody set to the following steps given a response response and responseBody:
-
If responseBody is null or failure, or response’s status is not an ok status, set sdJwt to failure and return.
-
Let json be the result of extract the JSON fetch response from response and responseBody.
-
Convert json to an
IdentityAssertionResponse, result. -
If one of the previous two steps threw an exception, or result["
error"] exists, set sdJwt to failure and return. -
If result["
continue_on"] exists, run these steps in parallel:-
Let continueOnUrl be the result of running parse url with result["
continue_on"] and globalObject. -
If continueOnUrl is failure, or it is not same origin with issuanceUrl, set sdJwt to failure and return.
-
Let tokenPair be the result of show a continuation dialog with continueOnUrl.
-
If tokenPair is failure, set sdJwt to failure and return.
-
Set sdJwt to the first entry of tokenPair. If the second entry of tokenPair is not null, set accountId to it.
-
-
Otherwise, set sdJwt to failure.
-
-
Wait for sdJwt to be set.
-
If sdJwt is failure or is not a string, return failure.
-
Let token be the result of present a delegated credential with sdJwt, holderKey, provider, and globalObject.
-
Discard holderKey’s private key.
-
If token is failure, return failure.
-
Return (token, accountId).
The continuation dialog is opened from the RP’s page. Check that nothing in it (for example, the opener or the referrer) reveals the RP to the issuer.
4.3. Presenting a delegated credential
IdentityProviderRequestOptions provider, and a globalObject, run the following steps. This returns a string or failure.
-
Let parts be the result of strictly splitting sdJwt on "
~". -
If parts has fewer than 2 items, or its last item isn’t the empty string, return failure.
Note: The last item being empty means sdJwt doesn’t already have a key binding JWT.
-
Let issuerJwt be parts[0], and disclosures be the remaining items of parts except the last.
-
Let payload be the result of decoding the payload of issuerJwt as a JWT JSON Web Token (JWT). If that fails, return failure.
-
If payload["
cnf"]["jwk"] doesn’t exist, or isn’t the same key as holderKey’s public key (for example, by comparing their JWK thumbprints JSON Web Key (JWK) Thumbprint), return failure. -
Let hashAlgorithm be payload["
_sd_alg"] if it exists, and "sha-256" otherwise. If the user agent doesn’t support hashAlgorithm, return failure. -
Let fields be provider["
fields"] if it exists, and « "name", "email", "picture" » otherwise. -
Let allowedNames be the items of fields that are in the list of recognized fields.
-
Let selected be an empty list.
-
For each disclosure of disclosures:
-
Let decoded be the result of base64url-decoding disclosure and parsing it as JSON. If that fails, or decoded isn’t an array of 3 elements whose second element is a string, continue.
-
If the base64url encoding of the hashAlgorithm digest of disclosure isn’t in payload["
_sd"], continue. -
Append disclosure to selected.
-
-
Let presented be issuerJwt, followed by "
~", followed by the items of selected each followed by "~". -
Let kbJwt be a JWT JSON Web Token (JWT), signed with holderKey’s private key using
ES256JSON Web Signature (JWS), whose header is{"typ": "kb+jwt", "alg": "ES256"}and whose payload has the following claims:iat-
The current time, in seconds since the epoch.
aud-
The serialization of globalObject’s associated Document’s origin.
nonce-
provider["
nonce"]. sd_hash-
The base64url encoding of the hashAlgorithm digest of presented.
-
Return presented followed by kbJwt.
Note: Fetch an identity assertion is only reached after the user agreed to share
the RP’s fields, or after an earlier sign-in that
connected the account to the RP.
The user agent never discloses more than the fields the RP requested.
When the user agent doesn’t ask for permission (for example, for a returning user), should the user agent disclose the fields that the user agreed to share last time, rather than the fields that the RP requests now?
Should the user agent reject a delegated credential that has the user’s claims in the clear, since it can’t keep them from the RP?
4.4. Changes to FedCM
-
If config is a delegated config:
-
Let result be the result of fetch a delegated credential with accountId, provider, config, and globalObject.
-
If result is failure, queue a global task on the DOM manipulation task source given globalObject to let error be the result of create an IdentityCredentialError given {} and globalObject. Wait for error to be set, and return it.
-
Set tokenValue to result’s first item and accountId to its second item, and continue with the step "If isAutoSelected is false, create a connection between the RP and the IdP account", skipping the steps in between.
-
Note: The connection is stored only in the user agent’s connected accounts set. The issuer doesn’t learn about it.
Note: Fetching the client metadata sends client_id to the IDP, which would reveal the RP.
Without it, request permission to sign-up doesn’t show the RP’s privacy policy or terms of
service. The user agent can still tell whether the RP is third-party to the top-level frame by
itself, without the client_is_third_party_to_top_frame_origin value from the IDP.
Where should a verifier’s privacy policy and terms of service come from, if not from the IDP? For example, the RP could pass them itself.
-
If config is a delegated config, remove all connections given the embedder origin, the RP origin, and the IDP origin, as computed later in this algorithm, resolve promise, and return.
Note: The disconnect endpoint is never fetched for a delegated config, because
send a disconnect request sends client_id to the IDP, and the IDP has nothing to
disconnect: it never learned about the connection.
5. Privacy considerations
| Request | Credentials | Reveals the RP? |
|---|---|---|
| Config file and well-known file | No | No |
| Accounts endpoint | Yes | No |
| Client metadata endpoint | Not fetched for a delegated config | |
| VC issuance endpoint | Yes | No |
| Disconnect endpoint | Not fetched for a delegated config | |
-
**What the issuer learns.** The issuer learns that the user signed in somewhere, which account they used, and when, but not where. The public holder key is new for each sign-in, so it can’t link sign-ins together.
-
Timing. An issuer and an RP that collude can correlate sign-ins by time. Issuing credentials ahead of time, rather than during sign-in, would make that harder.
-
Collusion. If the RP shows the delegated credential to the issuer, the issuer can recognize it (for example, by its signature or its salts). This specification doesn’t protect against an RP that colludes with the issuer. Zero-knowledge proofs might.
-
**What the RP learns.** The RP learns the issuer, the claims the user agreed to disclose, and any claims that the issuer put in the clear. A stable identifier in the clear, such as
sub, lets RPs correlate the user across sites (the RP Tracking Problem). Issuers SHOULD make every claim about the user selectively disclosable. -
As in Federated Credential Management API, the accounts endpoint is fetched with credentials but without revealing the RP, and the RP learns nothing until the user selects an account.
How should an RP get a stable, but pairwise, identifier for the user, if the issuer doesn’t know who the RP is?
6. Security considerations
-
Replay. The key binding JWT binds the presentation to the RP’s origin (
aud) and to itsnonce. An RP that receives a presentation meant for another RP can’t use it there, as long as RPs checkaud,nonce, andiat. -
Holder key. Only the user agent ever has the private holder key, and only for the duration of a sign-in. An attacker that steals a delegated credential from the issuer’s response can’t present it, because it can’t sign the key binding JWT.
-
Issuer trust. As in Federated Credential Management API, the RP decides which issuers it trusts. It MUST check that
issis an issuer it trusts and verify the issuer’s signature. -
Disclosure. The user agent only discloses the claims that the RP requested and the user agreed to share. It can’t hide claims that the issuer put in the clear.
7. Acknowledgements
Thanks to the Federated Identity Community Group for their feedback on this proposal.