Skip to content

Credential issuance (OpenID4VCI)

A realm can issue verifiable credentials about its own users to their EU Digital Identity Wallets, over OpenID for Verifiable Credential Issuance 1.0 (HAIP-aligned): SD-JWT VC (dc+sd-jwt) and ISO mdoc (mso_mdoc), each bound to a key the wallet proves, each with a Token Status List entry so it can be revoked. (Preview, not yet tested with a real national wallet.)

Status: Preview — not yet tested with a real national wallet

New in 1.1.0-rc.1. No real wallet has been tested. Interoperability is proven against the project's own test wallet and Helix's own OpenID4VP verifier: issue, present, verify, revoke, and the same presentation refused.

Trust is yours to arrange

Helix issues and signs. Whether a wallet, a relying party or a national scheme trusts your issuer depends on the certificate chain of your issuer key and on the trust list or scheme you register with. Getting onto a trust list is a process between you and the scheme owner, outside Helix. Helix does not register you anywhere.

Concepts

  • A template is one credential the realm issues: a credential_configuration_id, a format, a type (vct or doctype), the claims and where their values come from, display, validity and proof policy.
  • An offer lets one user's wallet collect credentials once (a QR code or an openid-credential-offer:// link, with a transaction code). It is always shown to the user only.
  • The wallet redeems the offer at the realm's token endpoint (the pre-authorized code grant), or signs in through the normal Helix login (the authorization code flow), then proves a key and receives the credential.
  • Every credential points to an entry in a status list the realm publishes; revoking or suspending flips that entry.
  • Helix stores no claim values of issued credentials: ids, template, status pointer, holder-key thumbprint and times only.

Before you start

  • DB_ENCRYPTION must be set. Outside the dev profile, issuer keys are refused without it.
  • IDP_BASE_URL must be the public base URL. The credential issuer identifier is {IDP_BASE_URL}/realms/{realm} (or the realm's own issuer setting). It comes from configuration, never from the request's Host header, because it is signed into every credential (iss), names the status lists and is the audience of every key proof. Without it the realm cannot issue.
  • The admin permission manage-credential-issuance (included in realm-admin) covers templates, offers and issued credentials. Issuer key writes, the issuer settings and a template's BSN grant need realm-admin. See Admin roles.

Console: Federation & eIDs → Credential issuance (templates wizard and editor with a live preview, issued credentials, offers, issuer key, settings). Admin API: /admin/realms/{realm}/credential-issuance/…. The examples assume an admin session and the CSRF header, as in Authenticating to the API.

The issuer key

A separate EC P-256 / ES256 key, never published in the realm's OIDC JWKS: every credential carries its certificate chain (x5c / x5chain). A key goes PENDING → ACTIVE → ROTATED → RETIRED.

Way What happens
CSR (production) Helix generates the key and a PKCS#10 request; the private key never leaves the server. Your CA signs it; attach the chain (leaf first), then activate
Import Paste an EC P-256 private key and its chain. The leaf must not be self-signed
Generate test key A key certified by a per-realm development CA, activated at once. Verifiers trust it only if they add that CA certificate. For trying things out only
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/credential-issuance/keys" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

# 1. Generate the key and a certificate signing request
curl -s -b cookies.txt -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
  -d '{"subject":"CN=Example credential issuer,O=Example,C=NL"}' \
  "$HELIX_URL/admin/realms/$REALM/credential-issuance/keys/csr"
# → {"id": "...", "csrPem": "-----BEGIN CERTIFICATE REQUEST-----...", "status": "PENDING", ...}

# 2. Attach the chain your CA returned (leaf first)
jq -n --rawfile chain issuer-chain.pem '{certificateChainPem: $chain}' > chain.json
curl -s -b cookies.txt -X PUT -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
  -d @chain.json "$HELIX_URL/admin/realms/$REALM/credential-issuance/keys/$KEY_ID/certificate"

# 3. Activate it
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" \
  "$HELIX_URL/admin/realms/$REALM/credential-issuance/keys/$KEY_ID/activate"

In a realm with key custody (Protected or High assurance) the CSR key is generated in the realm's HSM/KMS, and Generate test key and Import are refused in High assurance. If the backend is unreachable, issuing answers 503 temporarily_unavailable and nothing is signed.

Activating a pending key rotates the active one; credentials already issued keep their own chain and stay valid. Status lists are always re-signed with the active key. A credential never outlives the issuer certificate: its expiry is capped at the certificate's notAfter.

Issuer settings and the wallet-client allow-list

PUT /admin/realms/{realm}/credential-issuance/settings (realm-admin; Terraform helix_credential_issuer):

Field Meaning
enabled off: the metadata, offers and every issuance endpoint answer as if the realm did not issue
displayName, logoUri (https), locale issuer display
offerTtlSeconds how long an offer stays open, 60–3600, default 600
walletProviderAnchors PEM certificates of the wallet providers whose wallet and key attestations you accept
walletClients the client ids of the realm's wallet apps: the only clients that may request a credential when a user signs in. A dynamically registered client is never a wallet client unless you list it
publishDidWeb publish the realm's did:web document

Templates

POST /admin/realms/{realm}/credential-issuance/templates (or the console wizard):

{
  "configurationId": "employee_badge",
  "format": "dc+sd-jwt",
  "vct": "https://id.example.com/vct/employee-badge",
  "display": {"name": "Employee badge", "locale": "en", "backgroundColor": "#12355b", "textColor": "#ffffff"},
  "claims": [
    {"name": "given_name", "source": "attribute:firstName", "disclosable": true, "mandatory": true},
    {"name": "department", "source": "attribute:department", "disclosable": true},
    {"name": "roles", "source": "roles", "disclosable": false}
  ],
  "validityDays": 365, "maxBatch": 2, "keyAttestation": "OPTIONAL", "walletAttestation": "OFF"
}
  • Formats: dc+sd-jwt needs vct; mso_mdoc needs doctype (namespace defaults to the doctype).
  • Claim sources: user.username, user.email, user.userId, attribute:<name>, roles (realm roles), organizations, const:<text>, and bsn (below). A missing mandatory claim refuses issuance; a missing optional one is left out. Names the format reserves (iss, cnf, vct, status, _sd, …) are refused.
  • Selective disclosure: in an SD-JWT VC, disclosable claims become salted disclosures (plus decoy digests); the others are always visible. In an mdoc every element is selectively disclosable.
  • requiredRole: only users with this realm role receive it. scope: the OAuth scope that requests it in the authorization code flow (default: the configuration id); never an OpenID Connect or system scope, or a scope the realm defines for OIDC. maxBatch: copies per request, 1–10.
  • keyAttestation and walletAttestation: OFF, OPTIONAL or REQUIRED.
  • signingMethod: x5c (default) or did_web (below).
  • A template with valid credentials cannot be deleted; disable it, or revoke them first.

The BSN. A credential carries the citizen service number only when the template has a legal-basis grant recorded by a realm administrator (PUT …/templates/{id}/bsn-release with {"legalBasis": "..."}, audited). Without it a bsn claim is refused at save and at issuance. While a grant exists, any change to the template needs realm-admin; offering it needs realm-admin; and a BSN credential is issued only to an allow-listed wallet client.

No BSN through any other claim. Every other claim — whatever its source: attribute:, const:, the username, e-mail, roles or organisations — goes through the same central BSN detector as OIDC claims and verified_claims:

  • a claim whose name names a BSN or another national person identifier (bsn, burgerservicenummer, citizen_service_number, personal_administrative_number, nin, national_id, … in any case, with _, - or in camelCase) is refused when the template is saved (400 on claims[i].name) unless its source is bsn, and dropped at issuance;
  • a claim whose value is or contains a BSN in any written form (123456782, 123 456 782, NL123456782, BSN:123456782, NL/NL/123456782) is dropped at issuance; a const: value of that kind is refused at save. A mandatory claim that is dropped refuses issuance, like a missing one.

Never a PID. A template's vct may not be a PID type (urn:eudi:pid:…, or any type a wallet connection of this deployment accepts as its PID), its doctype and namespace not a PID doctype (eu.europa.ec.eudi.pid.…, or a connection's mdoc PID doctype), and no claim may be named personal_administrative_number or issuing_country (the claims a wallet sign-in identifies the citizen by). A 400 names the field, and issuance refuses such a template again (credential_request_denied). Whoever manages credential issuance decides what this issuer signs, so it must never be able to write somebody's identity.

Your own wallet connections and this issuer

A wallet connection of this deployment (any realm) never trusts this issuer for the PID: its did:web, its keys, its certificates and the CA that issued them are refused as PID issuers (see Credentials beyond the PID). It may trust it for another credential, but then trusting your own issuer means trusting the administrators who hold manage-credential-issuance in that realm for every non-admin role you map from that credential; roles with an admin permission are never granted from it.

Offers: always to the user

The user always gets the offer, never the administrator.

  • In the account console, Digital credentials lists the templates the signed-in user may receive. Add to wallet shows the QR code, the Open wallet app link and a 6-digit transaction code. It needs a recent sign-in, is rate limited per user, and is refused while an administrator impersonates the user.
  • An administrator offers a credential with POST /admin/realms/{realm}/credential-issuance/offers. The answer carries no code, no link and no transaction code. The offer waits in the user's account console under Waiting for you, and the user gets a notice e-mail (template credential-offer-notice, which links to the account console and carries no code). When the user opens it, fresh codes are made and shown only to them.
curl -s -b cookies.txt -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
  -d "{\"userId\":\"$USER_ID\",\"configurationIds\":[\"employee_badge\"],\"txCode\":true}" \
  "$HELIX_URL/admin/realms/$REALM/credential-issuance/offers"
# → {"id": "...", "state": "...", "expiresAt": "...", "emailed": ...}   (no code, no link, no transaction code)

The user must be of this realm, enabled and eligible; offering a credential counts as acting on that user, so you cannot offer one to an administrator with more permissions than yours, and never to yourself (403): you decide what the template says, so another administrator offers it to you, or you take it from your own account console. An open offer can be withdrawn with DELETE …/offers/{id}.

Offers are by reference: {issuer}/oid4vci/offers/{id} serves the same offer object on every GET (wallets retry) until it is redeemed, withdrawn or expires. The transaction code is stored only as a salted hash; five wrong attempts withdraw the offer.

How a wallet collects a credential

Pre-authorized code

  1. The wallet reads the issuer metadata at {issuer}/.well-known/openid-credential-issuer (also served at the path-insertion form {base}/.well-known/openid-credential-issuer/realms/{realm}).
  2. It redeems the code at the realm's token endpoint, POST {issuer}/oauth2/token, with grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code, pre-authorized_code and tx_code (anonymous access). With a DPoP header the token is bound to that key. The answer is an opaque access token (5 minutes) with authorization_details naming the configurations and their credential_identifiers.
  3. It takes a nonce: POST {issuer}/oid4vci/nonce → {"c_nonce": ...} (single use, 5 minutes).
  4. It sends key proofs to POST {issuer}/oid4vci/credential, with credential_configuration_id (or credential_identifier) and proof or proofs.jwt[] (a batch, up to the template's maxBatch).
  5. It confirms with POST {issuer}/oid4vci/notification (credential_accepted, credential_failure, credential_deleted).

The token issues each configuration of its offer once: the first successful response spends it, a repeat is refused with credential_request_denied (of concurrent duplicates exactly one succeeds), and a configuration not in the offer is refused too.

The issuer metadata is public:

curl -s "$HELIX_URL/realms/$REALM/.well-known/openid-credential-issuer"

Authorization code

Only the realm's wallet clients (the allow-list in the issuer settings) may request a credential when a user signs in, through /oauth2/authorize (or PAR) with the template's scope, or with authorization_details=[{"type":"openid_credential","credential_configuration_id":"employee_badge"}].

  • Any other client asking for a credential scope or openid_credential details is refused (400 unauthorized_client); an unknown or disabled configuration is 400 invalid_authorization_details.
  • prompt=none with a credential request is consent_required: issuance needs the user.
  • The wallet need not send resource: the credential issuer is added to the token's aud server-side.

The credential endpoint accepts such a token only from the authorization_code grant, for a wallet client, with no act claim (no delegated or agent token), not obtained while an administrator impersonated the user, for an enabled user of the realm, and with its sender constraint holding. client_credentials, token exchange, device, CIBA and every other grant are refused.

Proofs, attestations and DPoP

  • Key proof (openid4vci-proof+jwt): ES256 only; the key in a jwk header (public EC P-256), or named by a kid that is a did:jwk or did:key DID URL (resolved offline; never did:web); aud = the issuer identifier; iat within 60 s and not older than 5 minutes; the request's c_nonce. Every credential is bound to its proof key (cnf.jwk in an SD-JWT VC, the device key in an mdoc).
  • Key attestation (per template): a key_attestation header chained to the wallet-provider anchors whose attested_keys contain the proof key.
  • Wallet attestation (per template): OAuth-Client-Attestation and OAuth-Client-Attestation-PoP at the pre-authorized code grant. An invalid attestation is always invalid_client.
  • Attestations must carry iat and be at most helix.issuer.attestation.max-age old (default 24 h).
  • DPoP (RFC 9449) at the token, credential and notification endpoints: Authorization: DPoP <token> with a DPoP proof whose key thumbprint equals the token's binding, a fresh iat, a jti never seen before on any replica, and ath of the token. Default ports in htu are normalised. A DPoP-bound token is never accepted as a bearer token, and a certificate-bound token is refused at these endpoints.

Status lists and revocation

  • Lists of 131 072 entries, 2 bits each (0 valid, 1 revoked, 2 suspended), one entry per credential. Indices are drawn at random among never-used ones and never reused, and a list stops taking entries at half full, so an index reveals neither issuance order nor neighbours.
  • Published at GET {issuer}/oid4vci/status-lists/{id}: application/statuslist+jwt by default, application/statuslist+cwt with that Accept, Cache-Control: public, max-age=300, served while the realm is disabled too.
  • Lists whose entries changed, or whose token is past half its 24-hour life, are re-signed with the active issuer key every helix.issuer.status-list.resign-interval (default 60 s). A revocation reaches verifiers within one interval plus their cache.
  • Admin: Credential issuance → Issued credentials → Suspend / Reinstate / Revoke (revoked is final), or the admin API:

    curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" \
      "$HELIX_URL/admin/realms/$REALM/credential-issuance/credentials/$CREDENTIAL_ID/revoke"
    

    (…/suspend and …/reinstate work the same way.) - Automatic: when a user is disabled or removed from the realm, their credentials are revoked, at once through the user admin API and by a periodic job for every other path (SCIM, GDPR erasure, imports).

did:web signing

The EUDI framework identifies credential issuers by X.509; EU wallets and EUDI verifiers do not look up DIDs. This is optional, for ecosystems outside that framework, and X.509 stays the default.

  • Publish the realm's did:web: issuer settings → did:web → Publish did:web (publishDidWeb: true, realm-admin; Terraform helix_credential_issuer.publish_did_web). The DID is derived from the issuer identifier: https://id.example/realms/acme gives did:web:id.example:realms:acme, served at GET /realms/{realm}/did.json. See DIDs.
  • Sign with it: template → Signing → did:web key id (signingMethod: "did_web", Terraform signing_method). The SD-JWT VC header then carries kid: "did:web:…#<thumbprint>" without x5c, and iss is the DID. SD-JWT VC only; an mdoc is always signed with x5chain. While the did:web is not published, issuing such a template is refused (credential_request_denied). While it is published, the status lists carry the same kid next to their x5c.

EU wallets expect x5c: use did:web only for verifiers you know resolve DIDs.

Terraform

Resource What it manages
helix_credential_issuer the issuer settings: enabled, display_name, logo_uri, locale, offer_ttl_seconds, wallet_provider_anchors, wallet_clients, publish_did_web
helix_credential_template a template: configuration_id, format, vct or doctype / namespace, claims (name, source, disclosable, mandatory), display, validity_days, max_batch, required_role, scope, key_attestation, wallet_attestation, signing_method, enabled

Issuer keys, offers and issued credentials are not Terraform resources. See Terraform provider.

Audit and privacy

Audit events: CREDENTIAL_OFFER_CREATED, CREDENTIAL_OFFER_REVOKED, CREDENTIAL_ISSUED, CREDENTIAL_ISSUE_REFUSED, CREDENTIAL_REVOKED, CREDENTIAL_SUSPENDED, CREDENTIAL_REINSTATED, CREDENTIAL_NOTIFICATION and ACCOUNT_CREDENTIAL_OFFER, plus the admin-API events of every write. They name ids, the configuration, the format and reasons, never claim values. Issued-credential records are deleted helix.retention.issued-credential-grace (default 30 days) after the credential expired.

Endpoints and limits

Endpoint Rate limit
GET /realms/{realm}/.well-known/openid-credential-issuer —
POST /realms/{realm}/oauth2/token (pre-authorized code) the token endpoint's
POST /realms/{realm}/oid4vci/nonce, /oid4vci/credential, /oid4vci/notification; GET /realms/{realm}/oid4vci/offers/{id}, /oid4vci/status-lists/{id} helix.ratelimit.issuer.* (default 120 a minute per client IP)

Request bodies are capped at 64 KB, a proof at 16 KB.

Admin API (under /admin/realms/{realm}/credential-issuance) Purpose
GET /keys · GET /keys/ca · POST /keys/generate · POST /keys/import · POST /keys/csr Issuer keys, the development CA, the three ways to add a key
PUT /keys/{keyId}/certificate · POST /keys/{keyId}/activate · POST /keys/{keyId}/retire Attach a chain, activate, retire
GET · PUT /settings Issuer settings
GET · POST /templates · GET · PUT · DELETE /templates/{id} Templates
PUT · DELETE /templates/{id}/bsn-release The BSN legal-basis grant
GET · POST /offers · DELETE /offers/{id} Offers
GET /credentials · POST /credentials/{id}/revoke · POST /credentials/{id}/suspend · POST /credentials/{id}/reinstate Issued credentials
GET /did-document Preview of the realm's did:web document

What is deferred

  • No real wallet tested. Proven only against the project's own test wallet and verifier.
  • Deferred issuance (deferred_credential_endpoint) is not offered: every credential is issued at once, from data the realm already holds.
  • Not in this release: encrypted credential responses, signed issuer metadata, DPoP server nonces (DPoP-Nonce), mTLS-bound tokens at the credential endpoint, wallet attestation in the authorization code flow, did:web holder keys, ldp_vc / jwt_vc_json, the attestation proof type, did:web for mdoc, the host-wide /.well-known/did.json.
  • The development CA is for trying things out; production issuers use a CSR signed by their own CA.

See also