Skip to main content

Keycloak Issuer Integration

This guide explains how to use Keycloak as the identity provider for credential issuance, allowing authenticated Keycloak users to receive digital credentials in their SIROS ID wallet. After reading this guide, you will understand how to:

  • Configure Keycloak as the authentication backend for credential issuance
  • Map Keycloak user attributes to credential claims
  • Set up OIDC or SAML integration
  • Trigger credential issuance from Keycloak workflows

Overview​

When using Keycloak as the identity provider for credential issuance, users authenticate through Keycloak first, then receive credentials based on their verified identity attributes. This is ideal for organizations that already manage user identities in Keycloak.

Hosted or Self-Hosted

This guide works with both the SIROS ID hosted issuer and self-hosted deployments. The examples use issuer.example.org as a placeholder—replace with your actual issuer URL. See Issuer Deployment Options for more information.

SIROS Hosted Service

If using the SIROS-hosted issuer service, your URL will follow the pattern:

https://<tenant>.issuer.id.siros.org

For example: https://main.demo.issuer.id.siros.org

Prerequisites​

  • Keycloak 22+ installed and running
  • Admin access to your Keycloak realm
  • A SIROS ID issuer (hosted or self-hosted)
  • Users with attributes you want to include in credentials

Step 1: Register the Issuer as a Keycloak Client​

Create an OIDC client in Keycloak for the SIROS ID issuer.

  1. Open Keycloak Admin Console
  2. Select your realm
  3. Navigate to Clients → Create client

Client Configuration​

SettingValueDescription
Client typeOpenID ConnectProtocol type
Client IDsiros-issuerIdentifier for the issuer
NameSIROS ID Credential IssuerDisplay name
Always display in UIOFFHide from account console

Capability Config​

SettingValue
Client authenticationON
AuthorizationOFF
Authentication flow✅ Standard flow

Access Settings​

Configure the redirect URLs for your issuer:

SettingValue
Root URLhttps://issuer.example.org
Valid redirect URIshttps://issuer.example.org/oidcrp/callback
Valid post logout redirect URIshttps://issuer.example.org
Web originshttps://issuer.example.org

For self-hosted issuers, replace with your issuer URL.

Credentials​

After creating the client, go to the Credentials tab and copy the Client secret.

Step 2: Configure User Attributes​

Ensure Keycloak users have the attributes needed for credential claims.

Built-in Attributes​

These attributes are available by default:

Keycloak AttributeOIDC ClaimDescription
usernamepreferred_usernameUser's username
emailemailEmail address
firstNamegiven_nameFirst name
lastNamefamily_nameLast name

Custom User Attributes​

For credential-specific claims (birthdate, nationality, etc.):

  1. Navigate to Users → Select a user → Attributes
  2. Add custom attributes:
KeyExample Value
birthdate1990-01-15
nationalitySE
personal_id199001150123

Bulk Import Attributes​

For existing users, use the Admin REST API:

curl -X PUT "https://keycloak.example.com/admin/realms/myrealm/users/{user-id}" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"attributes": {
"birthdate": ["1990-01-15"],
"nationality": ["SE"],
"personal_id": ["199001150123"]
}
}'

Step 3: Configure Client Scopes​

Create scopes to expose user attributes as OIDC claims.

Create a Credential Scope​

  1. Navigate to Client scopes → Create client scope
  2. Configure:
SettingValue
Namecredential-claims
TypeDefault
Display on consent screenON
Consent screen textShare identity attributes for credential issuance

Add Attribute Mappers​

In the new scope, go to Mappers → Configure a new mapper → User Attribute

Birthdate Mapper​

SettingValue
Namebirthdate
User Attributebirthdate
Token Claim Namebirthdate
Claim JSON TypeString
Add to ID tokenON
Add to access tokenOFF
Add to userinfoON

Nationality Mapper​

SettingValue
Namenationality
User Attributenationality
Token Claim Namenationality
Claim JSON TypeString
Add to ID tokenON
Add to access tokenOFF
Add to userinfoON

Personal ID Mapper​

SettingValue
Namepersonal_id
User Attributepersonal_id
Token Claim Namepersonal_id
Claim JSON TypeString
Add to ID tokenON
Add to access tokenOFF
Add to userinfoON

Assign Scope to Client​

  1. Go to Clients → siros-issuer → Client scopes
  2. Click Add client scope
  3. Select credential-claims → Add → Default

Step 4: Configure the SIROS ID Issuer​

Configure the issuer to use Keycloak as its authentication backend.

For Hosted Issuer​

Contact SIROS ID support to configure your Keycloak as an IdP, providing:

  • Keycloak realm URL: https://keycloak.example.com/realms/myrealm
  • Client ID: siros-issuer
  • Client Secret: (from Step 1)
  • Required scopes

For Self-Hosted Issuer​

Create or update config.yaml:

common:
mongo:
uri: mongodb://mongo:27017
credential_metadata:
pid:
vctm_file_path: "/metadata/vctm_pid.json"
format: "dc+sd-jwt"

apigw:
api_server:
addr: :8080
public_url: "https://issuer.example.com"

auth_providers:
# Keycloak as the OIDC Provider
oidc:
enable: true
issuer_url: "https://keycloak.example.com/realms/myrealm"
redirect_uri: "https://issuer.example.com/oidcrp/callback"
registration:
preconfigured:
enable: true
client_id: "siros-issuer"
client_secret: "keycloak-client-secret"
scopes:
- openid
- profile
- email
- credential-claims
# Normalise Keycloak's claim names where they differ from the VCTM's
attribute_mapping:
birthdate:
claim: "birth_date"

# The OIDC claims ARE the credential data for this scope
data_sources:
assertion:
scopes:
pid:
auth_provider: oidc

PKCE is always used by the OIDC RP; there is no use_pkce switch. APIGW serves the callback at /oidcrp/callback, so redirect_uri must be that path on your public_url, and the same value must be registered in Keycloak.

Docker Compose with Keycloak​

A working issuer needs the apigw, issuer and registry services plus MongoDB; the OIDC client to Keycloak lives in apigw. Use the Compose file from Deployment unchanged and put the Keycloak client credentials in config.yaml as shown above. The config file is not environment-interpolated, so keep the client secret inline or in the file named by common.secret_file_path.

Step 5: Configure Credential Types​

Map Keycloak claims to specific credential types.

Person Identification Data (PID)​

common:
credential_metadata:
pid:
vctm_file_path: "/metadata/vctm_pid.json"
format: "dc+sd-jwt"

apigw:
data_sources:
assertion:
scopes:
pid:
auth_provider: oidc
# Value transformations are derivations on the scope
derivations:
- country_alpha2:
input: resident_country
auth_providers:
oidc:
attribute_mapping:
# Required PID claims — only entries that need renaming;
# standard OIDC claim names pass through unchanged.
birthdate:
claim: "birth_date"

# Optional PID claims released by Keycloak under a different name
country:
claim: "resident_country"

# A claim every credential should carry, even if the IdP omits it
issuing_country:
claim: "issuing_country"
default: "SE"

Claims the IdP does not release at all (issuance_date, issuing_authority) are filled in by the issuer at signing time, not by configuration here.

European Health Insurance Card (EHIC)​

EHIC data does not come from the IdP — the health insurance institution is the authentic source. Declare the type, then bind the scope to the datastore data source, using OIDC only to identify the person:

common:
credential_metadata:
ehic:
vctm_file_path: "/metadata/vctm_ehic.json"
format: "dc+sd-jwt"

apigw:
data_sources:
datastore:
scopes:
ehic:
auth_provider: oidc
# Claims used to look the person up in the datastore. Names are
# matched verbatim against the stored identity fields.
auth_claims: ["given_name", "family_name", "birth_date"]
auth_providers:
oidc:
attribute_mapping:
# Keycloak releases `birthdate`; the stored field is `birth_date`
birthdate:
claim: "birth_date"

The institution then pushes each person's EHIC document through the Datastore API.

Step 6: Test the Integration​

Using the Demo Wallet​

  1. Go to id.siros.org and create a wallet
  2. Navigate to Add Credential
  3. Enter your issuer URL or scan the QR code
  4. Authenticate with your Keycloak credentials
  5. Approve the credential issuance
  6. Verify the credential contains correct claims

Direct Testing​

The wallet-facing authorization flow is OpenID4VCI (pushed authorization request at /op/par, then /authorize), so it cannot be exercised with a bare browser URL. Create a credential offer from https://issuer.example.org/offers/pid and open it in a wallet.

Verify Claim Mapping​

Check that Keycloak claims appear correctly:

This check uses the password grant, which requires Direct access grants to be enabled on the client in Keycloak (turn it on temporarily on a test client; the production client JSON below leaves it off).

# Get a token from Keycloak directly
TOKEN=$(curl -s -X POST \
"https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token" \
-d "client_id=siros-issuer" \
-d "client_secret=${CLIENT_SECRET}" \
-d "grant_type=password" \
-d "username=testuser" \
-d "password=testpassword" \
-d "scope=openid profile credential-claims" | jq -r '.id_token')

# Decode and inspect claims
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq

Advanced Configuration​

SAML Integration​

If your organization uses SAML instead of OIDC, configure it under apigw.auth_providers.saml (not issuer.authentication, which no longer exists). Like the OIDC provider, attribute_mapping is keyed by the SAML attribute identifier, with the target claim name as the value:

apigw:
auth_providers:
saml:
enable: true
entity_id: "https://issuer.example.com/sp"
acs_endpoint: "https://issuer.example.com/samlsp/acs"
certificate_path: "/pki/sp-cert.pem"
private_key_path: "/pki/sp-key.pem"
attribute_mapping:
"urn:oid:2.5.4.42":
claim: "given_name"
"urn:oid:2.5.4.4":
claim: "family_name"
"urn:oid:0.9.2342.19200300.100.1.3":
claim: "email"
"urn:oid:1.3.6.1.5.5.7.9.1":
claim: "birth_date"

data_sources:
assertion:
scopes:
pid:
auth_provider: saml

Keycloak's SAML descriptor is discovered via mdq_server or a single static_idp_metadata entry, not metadata_url — see Using SAML Authentication for the full SAML provider field reference.

Conditional Credential Issuance​

There is no config-level "issue credential X only to role/group Y" gate — issuer.credential_policies (and required_roles/required_groups) do not exist in vc. What Keycloak roles and groups control is which OIDC scopes and attributes end up in the ID token in the first place (via client scope and mapper assignment, Step 3 above); apigw then only ever sees the claims Keycloak chose to release. Two ways to get role/group-gated behavior in practice:

  • Give each population its own credential-claims-style client scope in Keycloak, and map each scope to a different common.credential_metadata entry — a user without the scope simply cannot obtain that credential type, because APIGW never sees the OIDC scope requested.
  • For issuance triggered from your own backend rather than by the wallet, use the pre-authorized flow below and decide eligibility in your own code before calling the API.

Multi-Realm Support​

apigw.auth_providers.oidc configures exactly one OIDC provider — there is no providers: list, and issuer.authentication does not exist. A deployment cannot register two Keycloak realms as two separate OIDC providers in the same apigw instance today.

To serve users from more than one Keycloak realm, use Keycloak's own Identity Brokering: configure one realm (e.g. myrealm) as the OIDC provider apigw talks to, and add the other realms to it as brokered identity providers. Keycloak handles the realm selection and federates the login; apigw still only ever sees one issuer_url.

Automated Credential Provisioning​

Automatically issue credentials when users are created or updated:

// Keycloak Event Listener SPI
public class CredentialProvisioningListener implements EventListenerProvider {

@Override
public void onEvent(AdminEvent event, boolean includeRepresentation) {
if (event.getResourceType() == ResourceType.USER &&
event.getOperationType() == OperationType.CREATE) {

// Call POST /api/v1/datastore/preauth_offer (see
// "Pre-Authorized Code Flow" below) with this user's identity
// and the scope to issue. issuerClient is your own HTTP client,
// not part of vc.
issuerClient.createCredentialOffer(
event.getResourcePath(),
"pid"
);
}
}
}

Pre-Authorized Code Flow​

For server-initiated issuance (e.g., batch provisioning), skip OIDC entirely and create the offer through the datastore API — the scope's datastore entry must declare auth_provider: preauth (see API Integration):

curl -X POST "https://issuer.example.com/api/v1/datastore/preauth_offer" \
-H "Authorization: Bearer ${SERVICE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"authentic_source": "hr.example.org",
"scope": "pid",
"document_id": "keycloak-user-uuid"
}'

The response contains a credential offer that can be sent to users via email or displayed as a QR code, plus a tx_code when apigw.auth_providers.preauth.enable_pin is set.

Troubleshooting​

Authentication Fails​

Error: invalid_client or unauthorized_client

Solutions:

  1. Verify client ID matches exactly
  2. Check client secret is correct
  3. Ensure Client authentication is ON in Keycloak
  4. Verify redirect URI matches configured values

Claims Missing from Token​

Symptoms: Credential issued but missing attributes

Solutions:

  1. Verify user has the attributes set in Keycloak
  2. Check mapper configuration in client scope
  3. Ensure scope is assigned to client as Default
  4. Verify Add to ID token is ON for each mapper
  5. Test with userinfo endpoint:
curl -H "Authorization: Bearer ${ACCESS_TOKEN}" \
"https://keycloak.example.com/realms/myrealm/protocol/openid-connect/userinfo"

Credential Has Wrong Values​

Symptoms: Credential contains incorrect or default values

Solutions:

  1. Check the attribute_mapping entries (a flat claim-name to canonical-name rename; there is no JSONPath)
  2. Verify Keycloak attribute names match mapper configuration
  3. Enable issuer debug logging to see raw claims
  4. Test token claims directly from Keycloak

CORS Errors​

Error: Browser blocks cross-origin requests

Solutions:

  1. Add issuer URL to Keycloak client Web origins
  2. Configure CORS headers on issuer reverse proxy
  3. Verify both HTTP and HTTPS origins if needed

Configuration Reference​

Complete Keycloak Client JSON​

Import this client configuration:

{
"clientId": "siros-issuer",
"name": "SIROS ID Credential Issuer",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"https://issuer.example.com/oidcrp/callback"
],
"webOrigins": [
"https://issuer.example.com"
],
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"protocol": "openid-connect",
"defaultClientScopes": [
"openid",
"profile",
"email",
"credential-claims"
],
"optionalClientScopes": []
}

Complete Configuration​

A complete config.yaml for the apigw, issuer and registry services with Keycloak as the OIDC provider (as in Deployment):

common:
production: true
mongo:
uri: mongodb://mongo:27017
credential_metadata:
pid:
vctm_file_path: "/metadata/vctm_pid.json"
format: "dc+sd-jwt"

issuer:
issuer_url: "https://issuer.example.org"
api_server:
addr: :8080
grpc_server:
addr: :8090
registry_client:
addr: registry:8090
key_config:
private_key_path: "/pki/signing_ec_private.pem"
chain_path: "/pki/signing_ec_chain.pem"
jwt_attribute:
issuer: "https://issuer.example.org"
verifiable_credential_type: "urn:eudi:pid:1"

apigw:
api_server:
addr: :8080
public_url: "https://issuer.example.org"
key_config:
private_key_path: "/pki/signing_ec_private.pem"
chain_path: "/pki/signing_ec_chain.pem"
issuer_client:
addr: issuer:8090
registry_client:
addr: registry:8090
auth_providers:
oidc:
enable: true
issuer_url: "https://keycloak.example.com/realms/myrealm"
redirect_uri: "https://issuer.example.org/oidcrp/callback"
registration:
preconfigured:
enable: true
client_id: "siros-issuer"
client_secret: "keycloak-client-secret"
scopes:
- openid
- profile
- email
- credential-claims
attribute_mapping:
birthdate:
claim: "birth_date"
data_sources:
assertion:
scopes:
pid:
auth_provider: oidc
delivery:
openid4vci:
token_endpoint: "https://issuer.example.org/token"
clients:
"1003":
type: "public"
redirect_uri: "https://wallet.example.com"
scopes:
- "pid"
credential_offers:
issuer_url: "https://issuer.example.org"
wallets:
my_wallet:
label: "My Wallet"
redirect_uri: "https://wallet.example.com/credential-offer"
trust:
pdp_url: "http://go-trust:6001"

registry:
api_server:
addr: :8080
public_url: "https://registry.example.org"
grpc_server:
addr: :8090
token_status_lists:
key_config:
private_key_path: "/pki/signing_ec_private.pem"
chain_path: "/pki/signing_ec_chain.pem"

Credential validity is taken from the VCTM / issuance logic, not from a validity_days config key.

Next Steps​