What happens
A Custom OIDC provider (custom:<id>) drops every non-standard claim from the id_token before writing identity_data. Only these survive:
aud, email_verified, exp, family_name, given_name, iat, iss, name, phone_verified, sub
Why it matters
For Swedish BankID via a broker, the national identity number arrives as an ssn claim. It is the entire reason for using BankID in a healthcare context — it binds a clinician's login to their verified identity for record signing under Swedish Patientdatalagen. It never reaches the application.
The failure is silent. The login succeeds, the identity links, the user lands on the dashboard, and the claim is simply absent. Nothing in the API distinguishes "the IdP sent no claim" from "GoTrue dropped it" — we ran a production integration for a day believing it worked before querying the database.
The claim is definitely sent
Confirmed two independent ways:
- By the IdP (Idura, formerly Criipto), with a decoded id_token from their own sample flow:
{
"identityscheme": "sebankid",
"authenticationtype": "urn:grn:authn:se:bankid:another-device:qr",
"nameidentifier": "cf338be6b7ac45cd898cda5787fe8c87",
"sub": "{cf338be6-b7ac-45cd-898c-da5787fe8c87}",
"sessionindex": "6787af23-ef72-44ab-809a-1c22020af6a9",
"ssn": "199005152393",
"name": "Jan Aagaard Meier",
"givenname": "Jan Aagaard",
"given_name": "Jan Aagaard",
"surname": "Meier",
"family_name": "Meier",
"ipaddress": "185.157.134.134",
"country": "SE"
}
- By us in production, calling the IdP's UserInfo endpoint with
session.provider_token from exchangeCodeForSession. ssn is present, along with identityscheme, nameidentifier, sessionindex, ipaddress and country — none of which reached identity_data for the same login.
Scope configuration is not the cause
Three configurations, each with a fresh unlink and relink so identity_data was rewritten:
| IdP scope strategy |
Scope GoTrue forwards |
ssn in identity_data |
Static, openid |
openid |
no |
Static, openid ssn |
openid |
no |
Dynamic, openid ssn |
openid ssn |
no |
The scope leaving GoTrue was verified by inspecting the 302 from /auth/v1/authorize?provider=custom:<id>.
The fix already exists in this repo — just not for this provider
#1917 added RawClaims → customClaims → UserProvidedData.Metadata.CustomClaims to internal/api/provider/keycloak.go. The same treatment for the Custom OIDC provider would resolve this, and the precedent for whether such claims should be preserved is already settled by that PR being merged.
The Keycloak slot is not a usable workaround for non-Keycloak IdPs: it builds {url}/protocol/openid-connect/{auth,token,userinfo} and takes a realm base rather than a discovery document, so it 404s on the authorize hop.
Current workaround, for anyone hitting this
Take session.provider_token from exchangeCodeForSession and call the IdP's UserInfo endpoint directly. This works — it is what we ship today. But it depends on provider_token being returned for Custom OIDC, which is observed behaviour rather than documented, and it adds a network hop to every login.
Suggestion
Either port the keycloak.go passthrough to the Custom OIDC provider, or — if preserving arbitrary claims by default is unwanted — make it opt-in per provider, so integrators can name the claims they need. Silently discarding them with no signal is the part that costs the most.
What happens
A Custom OIDC provider (
custom:<id>) drops every non-standard claim from the id_token before writingidentity_data. Only these survive:Why it matters
For Swedish BankID via a broker, the national identity number arrives as an
ssnclaim. It is the entire reason for using BankID in a healthcare context — it binds a clinician's login to their verified identity for record signing under Swedish Patientdatalagen. It never reaches the application.The failure is silent. The login succeeds, the identity links, the user lands on the dashboard, and the claim is simply absent. Nothing in the API distinguishes "the IdP sent no claim" from "GoTrue dropped it" — we ran a production integration for a day believing it worked before querying the database.
The claim is definitely sent
Confirmed two independent ways:
{ "identityscheme": "sebankid", "authenticationtype": "urn:grn:authn:se:bankid:another-device:qr", "nameidentifier": "cf338be6b7ac45cd898cda5787fe8c87", "sub": "{cf338be6-b7ac-45cd-898c-da5787fe8c87}", "sessionindex": "6787af23-ef72-44ab-809a-1c22020af6a9", "ssn": "199005152393", "name": "Jan Aagaard Meier", "givenname": "Jan Aagaard", "given_name": "Jan Aagaard", "surname": "Meier", "family_name": "Meier", "ipaddress": "185.157.134.134", "country": "SE" }session.provider_tokenfromexchangeCodeForSession.ssnis present, along withidentityscheme,nameidentifier,sessionindex,ipaddressandcountry— none of which reachedidentity_datafor the same login.Scope configuration is not the cause
Three configurations, each with a fresh unlink and relink so
identity_datawas rewritten:ssninidentity_dataopenidopenidopenid ssnopenidopenid ssnopenid ssnThe scope leaving GoTrue was verified by inspecting the 302 from
/auth/v1/authorize?provider=custom:<id>.The fix already exists in this repo — just not for this provider
#1917 added
RawClaims→customClaims→UserProvidedData.Metadata.CustomClaimstointernal/api/provider/keycloak.go. The same treatment for the Custom OIDC provider would resolve this, and the precedent for whether such claims should be preserved is already settled by that PR being merged.The Keycloak slot is not a usable workaround for non-Keycloak IdPs: it builds
{url}/protocol/openid-connect/{auth,token,userinfo}and takes a realm base rather than a discovery document, so it 404s on the authorize hop.Current workaround, for anyone hitting this
Take
session.provider_tokenfromexchangeCodeForSessionand call the IdP's UserInfo endpoint directly. This works — it is what we ship today. But it depends onprovider_tokenbeing returned for Custom OIDC, which is observed behaviour rather than documented, and it adds a network hop to every login.Suggestion
Either port the
keycloak.gopassthrough to the Custom OIDC provider, or — if preserving arbitrary claims by default is unwanted — make it opt-in per provider, so integrators can name the claims they need. Silently discarding them with no signal is the part that costs the most.