# 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.)

!!! warning "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.

!!! note "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](../operations/admin-roles.md).

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](../getting-started/api-authentication.md).

## 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 |

```bash
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](../operations/key-custody.md#credential-issuer-keys-and-the-development-ca) (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](#didweb-signing) |

## Templates

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

```json
{
  "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](#didweb-signing)).
- 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](wallet-credentials.md#this-deployments-own-issuer)). 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](../integration/account-console.md), **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.

```bash
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:

```bash
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:

    ```bash
    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](dids.md#the-issuers-didjson).
- **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](../integration/terraform.md).

## 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

- [Account console](../integration/account-console.md) — where users add credentials to their wallet
- [DIDs](dids.md) — did:web, did:jwk and did:key
- [EU Digital Identity Wallet (OpenID4VP)](eudi-wallet.md) — the verifier side
- [Upgrading from rc.13 to 1.1.0-rc.1](../operations/upgrade-rc13-1.1.0-rc1.md) — `IDP_BASE_URL` and the new migrations
