# EU Digital Identity Wallet (OpenID4VP)

Let people sign in by presenting the PID (Person Identification Data) credential from an **EU Digital Identity Wallet**. HelixIAM acts as the OpenID4VP *verifier*: it asks the wallet for the PID, verifies the presentation end to end, and signs the user in to the realm.

!!! warning "Status: Beta (core); Preview (Digital Credentials API, verify-only)"
    The core verifier — redirect same-device, QR cross-device, `direct_post.jwt`, and SD-JWT VC and mdoc presentations — is **Beta** as of 1.0.0-rc.11: implemented and tested, but external conformance is not yet certified and it needs your own relying-party agreement to exercise end to end. The [Digital Credentials API](#the-w3c-digital-credentials-api) and [verify-only / step-up](#verify-only-step-up) are newer, layered on top, and carry a **Preview** label instead. What has been tested so far:

    - **The EU reference wallet.** The EU Digital Identity Wallet reference app for Android signed in with SD-JWT VC and mdoc PIDs from the EU test issuer, same-device and cross-device: all four combinations passed. For cross-device, the QR content was handed to the wallet by an Android intent, not scanned with the camera, and `x509_san_dns` was not tested. See [Interop: the EU reference wallet](#interop-the-eu-reference-wallet).
    - **The OpenID Foundation conformance suite**, run by the project itself. This is a self-run, **not an OpenID Certification**. See [Conformance suite run](#openid-foundation-conformance-suite-run).
    - **Not yet tested:** any national wallet, and the [Digital Credentials API](#the-w3c-digital-credentials-api) with a real wallet.

    Added in 1.1.0-rc.1, all **Preview** and not yet tested with a real national wallet: [credentials beyond the PID](wallet-credentials.md), [transaction confirmation](wallet-transaction-confirmation.md) with `verifier_info` and multi-signed Digital Credentials API requests, [DID issuers](dids.md), [verified data for applications](../integration/verified-claims.md), the [verification API](../integration/verification-api.md) and [credential issuance](credential-issuance.md).

!!! note "Bring your own registration"
    HelixIAM does **not** register you with a national wallet registrar, trust-list operator or scheme owner, and does not obtain an access or registration certificate for you. To accept real wallets you need your own relying-party registration, the X.509 access certificate and private key it gives you, and your own trust-list entry where one applies. Helix only uses what you already hold: you import the access certificate's private key and chain, and paste the certificate authorities that sign the PIDs you trust. Nothing in Helix is an eIDAS certification or a government approval.

## What it is

A wallet connection is an [identity provider](identity-providers.md) like any OIDC or SAML broker, with `protocol: "eudi-wallet"` (`openid4vp` and `wallet` are accepted too). It is configured per realm.

When a user picks the wallet on the login page:

1. Helix starts an OpenID4VP **transaction** and mints a signed, encrypted authorization request that asks for the PID.
2. The wallet answers with a **verifiable presentation**, as an SD-JWT VC (`dc+sd-jwt`), an ISO mdoc (`mso_mdoc`), or both.
3. Helix verifies it: the signature, the holder binding, the issuer's certificate chain against your trust anchors, the revocation status, and the requested claims.
4. Helix resolves the presentation to a local account ([subject strategy](#the-subject-administrative-number-else-link)) and completes the sign-in through the realm's normal post-login flow (MFA, required actions).

The relying party receives an ordinary OIDC ID token. For a wallet login it carries `acr: http://eidas.europa.eu/LoA/high`, the level the wallet broker asserts once a presentation verifies; there is no separate `substantial` tier for wallet logins.

| Capability | Supported |
| --- | --- |
| Protocol | OpenID4VP verifier, response mode `direct_post.jwt` (encrypted) |
| Credential formats | SD-JWT VC (`dc+sd-jwt`), ISO mdoc (`mso_mdoc`) |
| Flows | same-device (redirect to the wallet app), cross-device (QR code), and, opt-in per connection, the [W3C Digital Credentials API](#the-w3c-digital-credentials-api) (`dc_api.jwt`) |
| Revocation | IETF Token Status List, fetched and verified by Helix |
| Trust | PID issuer CA certificates you paste in (PEM), and/or [signed EU trusted lists](trusted-lists.md) that Helix fetches and verifies; optionally [DID issuers](dids.md) on an allow-list (Preview) |
| Linking | administrative number, else an explicit account link |
| Step-up | a [verify-only connection](#verify-only-step-up) confirms a signed-in user's own wallet and releases `verified_claims` |
| Other credentials | a driving licence, diploma or qualification next to the PID, each with its own trust ([Preview](wallet-credentials.md)) |
| Data for applications | per-application `verified_claims` in userinfo or tokens ([Preview](../integration/verified-claims.md)), or the [verification API](../integration/verification-api.md) without a sign-in (Preview) |
| Transactions | the user confirms a consent text or payment with the wallet ([Preview](wallet-transaction-confirmation.md)) |

## Before you start

**Set `DB_ENCRYPTION` first.** The verifier access key and the per-realm wallet pepper are stored encrypted. Outside the `dev` profile, importing a verifier key, creating a wallet connection and starting a wallet transaction are refused unless `DB_ENCRYPTION` is set. Set it **before** you create the realm's first wallet connection: a pepper created without a key is not re-encrypted later, and wallet sign-ins for that realm fail once a key is set or changed.

**Behind a proxy, set the proxy settings.** The wallet endpoints are rate limited per client IP and use the server's host names. See [Proxies, host names and rate limits](#proxies-host-names-and-rate-limits).

## In the console

1. Open **Federation & eIDs → Identity providers** and click **Add identity provider**.
2. Choose **EUDI Wallet (OpenID4VP)**. The alias defaults to `eudi-wallet`.
3. **Access key.** Import your verifier access key (private key and certificate chain) inline, or pick one you imported before.
4. **Credentials.** Choose the credential formats, the [PID claims](#pid-claims) to request, and paste the [trust anchors](#trust-anchors) or pick [trusted list sources](trusted-lists.md). The console previews each certificate and flags one that cannot be a trust anchor. **Name the trusted issuers in the request** sets [HAIP `trusted_authorities`](#haip-trusted_authorities-in-the-request) (Default / On / Off). **Other credentials**, **Combinations** and **DID issuers** add [credentials beyond the PID](wallet-credentials.md) and [DID issuers](dids.md) (Preview).
5. **Policies.** Set the [account subject](#the-subject-administrative-number-else-link) (or **Verify only (step-up)**, see [Verify-only](#verify-only-step-up)), the [revocation policy](#revocation-and-status-lists), the request and response settings, and the [Digital Credentials API](#the-w3c-digital-credentials-api).
6. Save. The connection is loaded at once and appears on the realm's login page.

The same form is the connection's detail page, with **Access key**, **Credentials** and **Policies** tabs, so you can come back to rotate the key, change trust anchors or adjust claims.

**Advanced settings.** The **Policies** tab ends with an **Advanced** block (collapsed while everything is at its default) for the expert keys: `clockSkewSeconds` (0–60, default 60), `requestUriMethod` (`get` / `post`), `invocationScheme` (default `haip-vp`; `javascript`, `data`, `vbscript`, `file`, `blob` and `about` are refused), `pid.sdJwtVcts` and `pid.mdocDoctype` (the accepted PID types), the parser limits `limits.maxJwtBytes` (64 KiB), `limits.cborMaxBytes` (256 KiB), `limits.cborMaxDepth` (16) and `limits.cborMaxItems` (10 000), and `allowLegacyVcSdJwtTyp`. A blank field keeps the server default, shown as the placeholder; values are validated as the server does. `customCredentialQueries` and `accessCertificateKeyRef` have no field on purpose: the validator refuses both.

**Federation → Verified data** lists every application's [verified-claims policy](../integration/verified-claims.md) and every verify-only connection's [step-up settings](wallet-step-up-and-profile.md), with remove and reset in the row menu.

## Over the API

The examples assume you have set `$HELIX_URL` / `$REALM` and logged in — see [Authenticating to the API](../getting-started/api-authentication.md). Every wallet endpoint below needs the `manage-identity-providers` admin permission, for reads and writes alike — **except `GET /trusted-lists`** (and its sub-paths), which needs only `view-identity-providers` as of 1.0.0-rc.11; writing a trusted list source still needs `manage-identity-providers`. Every write needs the CSRF header. See [Admin roles (RBAC)](../operations/admin-roles.md).

### Step 1 — Import the verifier access key

This is the EC (P-256, P-384 or P-521) private key and certificate chain from your relying-party registration. Send the private key as PKCS#8 PEM and the chain leaf first. RSA keys, a self-signed leaf, a chain out of order and a name already used in the realm are refused.

```bash
# Refresh CSRF after any GET, then POST (see the auth guide for the helper)
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/verifier-keys" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

# Build the request from your PEM files (jq keeps the line breaks intact)
jq -n --rawfile key access.key.pem --rawfile chain access-chain.pem \
  '{name: "primary", privateKeyPem: $key, certificateChainPem: $chain}' > verifier-key.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @verifier-key.json \
  "$HELIX_URL/admin/realms/$REALM/verifier-keys"
```
```json
{
  "keyRef": "software:master/primary",
  "name": "primary",
  "algorithm": "ES256",
  "createdAt": "2026-10-13T04:37:35.009617Z",
  "chainLength": 1,
  "certificate": {
    "subject": "C=NL,O=Example,CN=auth.example.com",
    "issuer": "C=NL,O=Example,CN=Example RP Access CA",
    "serialNumber": "175935999ced01eef1475f64a8813d653d95e150",
    "notBefore": "2026-10-13T04:37:17Z",
    "notAfter": "2027-04-11T04:37:17Z",
    "ca": false,
    "selfSigned": false,
    "dnsNames": ["auth.example.com"],
    "keyAlgorithm": "EC P-256",
    "trustAnchorProblem": "A trust anchor is not a CA certificate"
  }
}
```

The response is `201` with the key's **reference** (`keyRef`), which the connection uses, and the leaf certificate's public facts. The private key is write-only: it is never returned, logged or audited. (`trustAnchorProblem` describes the certificate as a *trust anchor* and is expected for an access-certificate leaf.) A refusal is a `400` with the usual shape:

```json
{
  "message": "A signing key named 'primary' already exists in this realm",
  "fieldErrors": { "name": "A signing key named 'primary' already exists in this realm" }
}
```

List the realm's keys (public facts only) with `GET`:

```bash
curl -s -b cookies.txt "$HELIX_URL/admin/realms/$REALM/verifier-keys"
```

!!! info "Key storage"
    The access key is stored in the database, encrypted with `DB_ENCRYPTION`. There is no HSM or KMS key provider for it today.

### Step 2 — Check your trust anchors

Trust anchors are the CA certificates that sign the PIDs you accept, pasted as PEM. Before you save, you can preview a bundle; nothing is stored:

```bash
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/verifier-keys" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq -n --rawfile pem pid-issuer-ca.pem '{pem: $pem}' > preview.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @preview.json \
  "$HELIX_URL/admin/realms/$REALM/wallet/certificates/preview"
```
```json
{
  "certificates": [
    {
      "subject": "C=DE,O=Example,CN=Example PID Issuer CA",
      "issuer": "C=DE,O=Example,CN=Example PID Issuer CA",
      "serialNumber": "30791c31a43e1786457a8529f8d9b14e49f28787",
      "notBefore": "2026-10-13T04:37:17Z",
      "notAfter": "2027-10-13T04:37:17Z",
      "ca": true,
      "selfSigned": true,
      "dnsNames": [],
      "keyAlgorithm": "EC P-256",
      "trustAnchorProblem": null
    }
  ]
}
```

`trustAnchorProblem: null` means the certificate can serve as a trust anchor. A bundle holds at most 50 certificates.

### Step 3 — Create the wallet connection

```bash
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/identity-providers" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq -n --rawfile anchors pid-issuer-ca.pem --arg keyRef "software:$REALM/primary" '{
  alias: "eudi-wallet",
  protocol: "eudi-wallet",
  displayName: "EU Digital Identity Wallet",
  enabled: true,
  config: {
    accessKeyRef: $keyRef,
    trustAnchors: $anchors,
    credentialFormats: "[\"dc+sd-jwt\",\"mso_mdoc\"]"
  }
}' > wallet-idp.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @wallet-idp.json \
  "$HELIX_URL/admin/realms/$REALM/identity-providers"
```
```json
{
  "realmId": "master",
  "alias": "eudi-wallet",
  "protocol": "eudi-wallet",
  "displayName": "EU Digital Identity Wallet",
  "enabled": true,
  "config": {
    "accessKeyRef": "software:master/primary",
    "trustAnchors": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n",
    "credentialFormats": "[\"dc+sd-jwt\",\"mso_mdoc\"]",
    "accessCertificatePrivateKeySet": false,
    "subjectPepperSet": false
  }
}
```

Use the `keyRef` from step 1 as `accessKeyRef`. The response is `201`. Secret keys are never returned: they come back as `…Set` flags (see [secrets are write-only](identity-providers.md#secrets-are-write-only)). Update and delete work like any provider: `PUT` / `DELETE /admin/realms/{realm}/identity-providers/{alias}`.

### Configuration keys

`config` is a flat map of strings. Nested settings are dotted, lists are JSON arrays of strings, enums are case-insensitive. Everything you leave out takes the default below.

| Key | Default | Meaning |
| --- | --- | --- |
| `accessKeyRef` | — | the verifier key reference from step 1 |
| `trustAnchors` | — | PEM CA certificates that sign the PIDs you accept; optional when `trustedLists.sources` is set (both may be set, and are combined) |
| `trustedLists.sources` | — | JSON array of the realm's [trusted list source](trusted-lists.md) names whose verified PID issuer anchors are trusted; an unknown name is a `400` |
| `trustedLists.serviceTypes` | every type the sources load | JSON array of service type URIs that narrows `trustedLists.sources` |
| `credentialFormats` | `["dc+sd-jwt","mso_mdoc"]` | which formats to ask for |
| `clientIdScheme` | `x509_hash` | how the verifier identifies itself to the wallet |
| `invocationScheme` | `haip-vp` | the URL scheme of the same-device "Open wallet" link |
| `responseMode` | `direct_post.jwt` | encrypted response; plain `direct_post` is allowed only in the `dev` profile |
| `requestUriMethod` | `get` | how the wallet fetches the request object: `get` or `post` |
| `dcql.trustedAuthorities` | *(unset)* | send HAIP's `trusted_authorities` in the DCQL query; unset means on for `x509_hash` with `direct_post.jwt`, off otherwise — see [HAIP `trusted_authorities`](#haip-trusted_authorities-in-the-request) |
| `dcApi.enabled` | `false` | also offer the [W3C Digital Credentials API](#the-w3c-digital-credentials-api) on the wallet page |
| `dcApi.request` | `signed` | `signed` (`openid4vp-v1-signed`), `unsigned` (`openid4vp-v1-unsigned`) or `multisigned` (`openid4vp-v1-multisigned`, [Preview](wallet-transaction-confirmation.md#multi-signed-digital-credentials-api-requests)) |
| `dcApi.expectedOrigins` | *(empty: the origin of `IDP_BASE_URL`)* | JSON array of the web origins a DC API response may be bound to |
| `dcApi.additionalKeyRef` | — | the second realm key of a multi-signed request |
| `verifierInfo.source` / `verifierInfo.format` / `verifierInfo.json` | `none` | OpenID4VP [`verifier_info`](wallet-transaction-confirmation.md#verifier_info) (Preview) |
| `credentials` / `credentialSets` | — | [credentials beyond the PID](wallet-credentials.md) and how they combine (Preview) |
| `holderBinding.sameKey` | `false` | refuse presentations bound to different holder keys |
| `didIssuers` | — | PID issuers identified by a [DID](dids.md) (Preview) |
| `transactionData.types` | — | verify-only: the [transaction types](wallet-transaction-confirmation.md) the connection may confirm (Preview) |
| `transactionTtlSeconds` | `300` | how long a sign-in may take, 60–900 |
| `pid.requestedClaims` | `["given_name","family_name","birthdate"]` | the [PID claims](#pid-claims) to request |
| `pid.sdJwtVcts` | `["urn:eudi:pid:1","urn:eudi:pid:de:1"]` | accepted SD-JWT VC types |
| `statusList.required` | `false` | refuse a PID that carries no `status` claim |
| `statusList.onUnavailable` | `FAIL_CLOSED` | or `FAIL_OPEN_WITH_AUDIT`; see [Revocation](#revocation-and-status-lists) |
| `statusList.maxCacheSeconds` | `900` | how long a fetched status list is cached, 0–900 |
| `statusList.allowedHosts` | `[]` (any https host) | status-list host names Helix may fetch from |
| `revocationChecking` | `HARD_FAIL` | issuer-certificate revocation (CRL / OCSP): `HARD_FAIL`, `SOFT_FAIL` or `OFF` — see [Issuer certificate revocation](#issuer-certificate-revocation-crl-ocsp) |
| `subject.strategy` | `ADMINISTRATIVE_NUMBER` | or `ATTRIBUTE_TUPLE`; see [the subject](#the-subject-administrative-number-else-link). `NONE` makes the connection [verify-only](#verify-only-step-up) |
| `subject.acknowledgeWeakSubject` | `false` | opt in to the weak attribute-tuple link (refused with `NONE`) |
| `verify.linkedProvider` | — | with `NONE` only, and then required: the alias of the realm's wallet sign-in connection whose links prove whose wallet it is |
| `verify.releasedClaims` | nothing | with `NONE` only: JSON array of the claims released as `verified_claims` |
| `minimumLoa` | `http://eidas.europa.eu/LoA/high` | minimum level of assurance |
| `clockSkewSeconds` | `60` | allowed clock skew |
| `limits.maxJwtBytes` | `65536` | size cap for a presentation and each JWT in it |

An invalid combination is refused with `400 {message, fieldErrors}` naming the field. For example, `ATTRIBUTE_TUPLE` without the acknowledgement:

```bash
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/identity-providers" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq '.config["subject.strategy"] = "ATTRIBUTE_TUPLE"' wallet-idp.json > wallet-idp-tuple.json

curl -s -b cookies.txt -X PUT \
  -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
  -d @wallet-idp-tuple.json \
  "$HELIX_URL/admin/realms/$REALM/identity-providers/eudi-wallet"
```
```json
{
  "message": "subject.strategy ATTRIBUTE_TUPLE requires subject.acknowledgeWeakSubject=true",
  "fieldErrors": {
    "config.subject.strategy": "subject.strategy ATTRIBUTE_TUPLE requires subject.acknowledgeWeakSubject=true"
  }
}
```

### Full endpoint set

| Method & path (under `/admin/realms/{realm}`) | Purpose |
| --- | --- |
| `GET /verifier-keys` · `POST /verifier-keys` | List / import verifier access keys |
| `POST /verifier-keys/generate` | Generate a verifier key in an HSM / KMS backend (see [Key custody](../operations/key-custody.md#wallet-verifier-keys)) |
| `POST /verifier-keys/{name}/csr` · `PUT /verifier-keys/{name}/certificate` | CSR for a generated verifier key / attach its certificate chain |
| `GET /trusted-lists` · `POST /trusted-lists` | List / add [trusted list sources](trusted-lists.md) |
| `GET` · `PUT` · `DELETE /trusted-lists/{id}` | Read / update (needs `configVersion`) / delete a source |
| `GET /trusted-lists/{id}/anchors` | The verified services of the source's last good list |
| `POST /trusted-lists/{id}/refresh` | Refresh a source now |
| `POST /trusted-lists/{id}/reset` | [Reset and reload](trusted-lists.md#tampering-and-reset-and-reload) a source |
| `POST /wallet/did/resolve` | Resolve a [DID](dids.md#resolve-and-show-keys) and show its keys (nothing stored) |
| `GET /flow-engine` | Whether the flow engine is on (`{"enabled": …}`), needed by [verify-only](#verify-only-step-up) after a password |
| `GET /wallet/metadata` | The PID claims, default claims and formats this server supports |
| `POST /wallet/certificates/preview` | Parse a PEM bundle and show each certificate (nothing stored) |
| `GET /identity-providers` · `POST /identity-providers` | List / create providers, including wallet connections |
| `GET` · `PUT` · `DELETE /identity-providers/{alias}` | Read / update / delete a provider |

## PID claims

Ask only for what you need. The default is `given_name`, `family_name` and `birthdate`. The server lists every claim it can request, and whether an mdoc PID carries it:

```bash
curl -s -b cookies.txt "$HELIX_URL/admin/realms/$REALM/wallet/metadata"
```
```json
{
  "pidClaims": [
    { "name": "family_name", "mdoc": true },
    { "name": "given_name", "mdoc": true },
    { "name": "birthdate", "mdoc": true },
    { "name": "place_of_birth", "mdoc": true },
    { "name": "nationalities", "mdoc": true },
    …
    { "name": "address.house_number", "mdoc": false },
    { "name": "personal_administrative_number", "mdoc": true },
    …
  ],
  "defaultRequestedClaims": ["given_name", "family_name", "birthdate"],
  "credentialFormats": ["dc+sd-jwt", "mso_mdoc"],
  "devProfile": true
}
```

`devProfile` is `true` only on a server running the `dev` profile (this capture), `false` elsewhere.

Claim names are the SD-JWT VC names, with nested members dotted (`address.country`); an mdoc presentation is mapped to the same names. A claim marked `"mdoc": false` is requested from SD-JWT VC only. `personal_administrative_number` and `issuing_country` are requested optionally for the subject.

What lands on the Helix user: `given_name`, `family_name`, `birthdate` (as `YYYY-MM-DD`), `nationalities`, `place_of_birth.*`, `address.*`, `issuing_country` and `issuing_authority`, plus `helix_wallet_format` (`dc+sd-jwt`, `mso_mdoc` or `both`) and `helix_wallet_issuer` (the issuer DN). The administrative number feeds the subject only and is never a user attribute; a PID carries no email. Your per-provider attribute mappers apply on top, as for every broker.

When a wallet answers in both formats, `given_name`, `family_name` and `birthdate` must agree or the login is refused.

## Trust anchors

`trustAnchors` holds the PEM CA certificates of the PID issuers you accept. A presentation whose issuer chain does not validate to one of them is refused. Pasted anchors do not update themselves; to have Helix follow a published list instead (or as well), use [trusted lists](trusted-lists.md).

For mdoc credentials, the signer of a status list must chain to the same issuing CA as the credential's signer. Helix checks this on the validated chain, never on the certificates the wallet presents.

### HAIP `trusted_authorities` in the request

HAIP requires verifiers to support DCQL's `trusted_authorities`. Each PID Credential Query can carry `{"type": "aki", "values": [...]}`: the base64url key identifiers of the connection's trust anchors and pinned intermediates. With trusted lists, the values follow the lists' current anchors at each request.

- **Default.** `dcql.trustedAuthorities` (console: **Credentials → Name the trusted issuers in the request**, Default / On / Off). Unset means **on** for the HAIP profile (`x509_hash` with `direct_post.jwt`, both defaults) and off otherwise, so existing HAIP-profile connections send it without a configuration change.
- **It only tells the wallet which issuers to offer.** Validating the chain to your anchors still decides what is accepted.
- **Key identifiers are never guessed.** If any anchor or pinned intermediate has no SubjectKeyIdentifier extension, the query leaves `trusted_authorities` out and the server logs one WARN when it loads the connection. Saving the setting explicitly **On** with such an anchor is a `400` on `dcql.trustedAuthorities`.
- **Escape hatch.** If a wallet answers with no credential (it filtered on `trusted_authorities` and found no match), set the connection to **Off**.

!!! warning "Rolling upgrades with wallet connections"
    A node of a release before `trusted_authorities` refuses a DCQL query that carries it. A wallet login started on an upgraded node and answered on an old node that shares the Redis or PostgreSQL transaction store therefore fails. Either upgrade every replica in one step; or drain wallet logins first (no new wallet logins on old nodes, then wait out `transactionTtlSeconds`, 300 s by default); or set HAIP-profile connections to **Off** (`dcql.trustedAuthorities=false`) for the duration of the roll and back to Default afterwards. The new release accepts queries with or without it.

## Trust anchors from EU trusted lists

Instead of (or as well as) pasting PEM anchors, a wallet connection can take its PID issuer anchors from **published, signed trusted lists** (ETSI TS 119 612 XML lists and lists of trusted lists, ETSI TS 119 602 LoTE JSON) that Helix fetches, verifies and refreshes itself. You add a source once per realm (**Federation & eIDs → Trusted lists**, `/admin/realms/{realm}/trusted-lists`) with its URL and **pinned signer certificates**, then name it in `trustedLists.sources`:

```json
"config": {
  "accessKeyRef": "software:master/primary",
  "trustedLists.sources": "[\"eudi-pid-providers\"]"
}
```

A list that cannot be fetched or verified keeps its last good anchors until its NextUpdate plus a grace period, then **fails closed** (`TRUST_LIST_UNAVAILABLE`). Signer rotation (the LOTL pivot), LoTE pointers, retry backoff, the integrity checks and **Reset and reload** are described on their own page: [Trusted lists](trusted-lists.md).

## The subject: "administrative number, else link"

Helix resolves a wallet sign-in to a local account in this order:

1. The PID carries `personal_administrative_number` **and** that number is linked to a user of the realm → sign in as that user.
2. The number is not linked, but the same holder's attribute tuple (below) **is** linked → sign in as that user and, only after the whole login has succeeded, also link the number to that user, so later sign-ins use the stronger number.
3. The number is unlinked and has no linked tuple → provision a new user just in time and link the number, when the account-linking policy allows just-in-time provisioning (it does by default).
4. The PID carries **no** administrative number → the wallet **cannot create an account**. The user must sign in another way first and link the wallet from the account console.

### Account linking

Users link a wallet from the **account console** (`/realms/{realm}/account`): the **EU Digital Identity Wallet** section has a **Link wallet** button. Linking needs a recent sign-in and runs the same wallet page (QR or same-device). Unlinking asks for a step-up and a confirmation. Both are audited (`ACCOUNT_WALLET_LINK`, `ACCOUNT_WALLET_UNLINK`).

Whenever a wallet is linked to an account — from the account console, or by the number upgrade in step 2 — the account's email address is told, and asked to unlink it and change the password if it was not them. The message uses the `wallet-linked-notice` template (English and Dutch defaults, editable under Messaging → Templates) and carries the realm name only, never PID data. No email address or no email provider means no notice.

### The weak attribute-tuple fallback

With `subject.acknowledgeWeakSubject=true`, a PID without an administrative number can still be **linked** (never auto-created) by an HMAC of `issuing_country`, `family_name`, `given_name` and `birthdate`, plus `place_of_birth.country/region/locality` exactly when `pid.requestedClaims` includes `place_of_birth`. The HMAC key is a per-realm pepper, stored encrypted.

!!! danger "Residual risk and stability"
    Two people who share the same tuple (name, birthdate, issuing country, and the configured place-of-birth parts) are indistinguishable: if one has linked a wallet, the other's wallet resolves to the same account. That is why the fallback is opt-in.

    Adding or removing `place_of_birth` in `pid.requestedClaims` changes every tuple this provider computes and orphans every existing tuple link. Those users keep their account but must link their wallet again. Administrative-number links are not affected.

For step-up without sign-in, see [Verify-only (step-up)](#verify-only-step-up) (`subject.strategy=NONE`).

## Revocation and status lists

Helix checks credential status with the IETF Token Status List, fetched and verified by Helix itself. The policy is asymmetric on purpose:

- **Always fails closed** when the status list cannot be trusted: a bad signature, an untrusted or expired chain, a wrong `typ` or `alg`, a `sub` that does not match the fetched URI, or a URI refused by policy (not `https`, blocked by the egress guard, or not in `statusList.allowedHosts`). A refused URI is never fetched.
- **May fail open** only with `statusList.onUnavailable=FAIL_OPEN_WITH_AUDIT`, and only for transient problems (a timeout, a 5xx, a DNS failure). The login then proceeds and an audit event `WALLET_STATUS_CHECK_FAILED` naming the wallet `transactionId` records that it did so without a revocation check.

The defaults are `onUnavailable=FAIL_CLOSED` (an unreachable list blocks sign-in) and `statusList.required=false` (a PID with no `status` claim is accepted).

Every status-list fetch goes through the same outbound URL guard as every other admin-configured outbound call: `https` only, no loopback, private, link-local or cloud-metadata destination. The connection goes to the address the guard checked (no DNS rebinding), TLS is still verified against the host name; connect 3 s, 5 s in total including DNS, body at most 1 MB. A TLS failure or an answer that is not valid HTTP fails closed even under `FAIL_OPEN_WITH_AUDIT`. Set `statusList.allowedHosts` to narrow it to your issuers' status hosts.

Verified status lists are cached in one cache shared by every wallet connection: `helix.wallet.status-list.cache-max-entries` (default `64`) and `helix.wallet.status-list.cache-max-bytes` (default 32 MB of decoded list bytes).

### Issuer certificate revocation (CRL / OCSP)

Separately from the credential's status list, the issuer's own signing certificate (and any intermediate below your trust anchor) is checked for revocation, per connection (`revocationChecking`; console: **Credentials → Issuer certificate revocation**):

| Mode | Status cannot be determined | Revoked |
| --- | --- | --- |
| `HARD_FAIL` (**default**) | refused (`ISSUER_UNTRUSTED`) | refused |
| `SOFT_FAIL` | accepted, with a warning naming the certificate | refused |
| `OFF` | not checked, nothing fetched | not checked |

Under `HARD_FAIL` a certificate that names **no** CRL or OCSP endpoint is refused: choose `SOFT_FAIL` for an issuer that publishes neither, or whose endpoints you cannot reach; `OFF` only for testing. Connections stored before 1.0.0-rc.9 without a value now run `HARD_FAIL`.

As of 1.0.0-rc.11, setting `revocationChecking` to `OFF` in the console requires an explicit confirmation: a checkbox ("I understand a credential signed with a revoked issuer key will be accepted") must be ticked before the form can be saved with `OFF`, repeated on the connection wizard's Review step. This closes an internal-pentest finding that the console warned about `OFF` but did not require any acknowledgement before saving it — see [Internal penetration test (2026-10-15)](../compliance/compliance-program.md#internal-penetration-test-2026-10-15).

Nothing is fetched until the chain validates to one of your trust anchors. Each certificate is checked on its own (OCSP first, then CRL distribution points, at most 2 URLs of each per certificate), over `http` or `https` through the outbound guard, without redirects: connect 3 s, 5 s per fetch, 10 s per chain. The JDK itself never fetches. Answers are cached until `nextUpdate` (at most 24 h), shared by every connection:

| Setting | Default | Meaning |
| --- | --- | --- |
| `helix.wallet.revocation.max-crl-bytes` | `10485760` (10 MB) | largest CRL downloaded (at most 100 MB) |
| `helix.wallet.revocation.cache-max-entries` | `256` | most cached OCSP responses and CRLs |
| `helix.wallet.revocation.cache-max-bytes` | `33554432` (32 MB) | most cached bytes |
| `helix.outbound.fetch.wallet.max-concurrent` | `16` | most issuer-revocation and status-list fetches at once (old name `helix.wallet.fetch.max-concurrent` still read) |

With every endpoint slow or unreachable, one credential can take about 25 s to verify; cached answers make the usual case fast.

## Same-device and cross-device flows

Both start from the realm's login page and land on the same wallet page:

- **Cross-device (QR).** The page shows a QR code. The user scans it with the wallet on their phone; the browser polls the transaction's status and signs in when the wallet has answered.
- **Same-device.** The **Open wallet** link (a `haip-vp://` URL by default) hands the request to the wallet app on the same device, which returns the browser to Helix to finish.

Either flow accepts SD-JWT VC or mdoc, as configured. The wallet fetches the request from, and posts its response to, URLs Helix builds from `IDP_BASE_URL`, never from the incoming request:

| Path | Called by |
| --- | --- |
| `GET /realms/{realm}/broker/{alias}` | the browser — starts a wallet transaction |
| `GET /realms/{realm}/broker/{alias}/wallet` | the browser — the wallet page (QR and Open-wallet link) |
| `POST /realms/{realm}/broker/{alias}/wallet/status` | the browser — polls the transaction (CSRF-protected) |
| `POST /realms/{realm}/broker/{alias}/wallet/dc-api/request` · `…/dc-api/response` | the browser — the [Digital Credentials API](#the-w3c-digital-credentials-api), when the connection turns it on |
| `GET`/`POST /realms/{realm}/oid4vp/{alias}/request/{requestId}` | the wallet — fetches the signed request |
| `POST /realms/{realm}/oid4vp/{alias}/response/{responseId}` | the wallet — posts the encrypted response |

**Request objects can be fetched again until the response arrives.** The first fetch of a `request_uri` binds the login to that flow (same-device or cross-device); the other invocation is `404` from then on. The same `request_uri` returns the same request again until the wallet has posted its response; after that, or once the login ended or expired, it is `404`. The response itself is single use. A `request_uri_method=post` re-fetch echoes the `wallet_nonce` of *that* POST, so it is not byte-identical to the first fetch. (Before this change each `request_uri` was served once; the OpenID Foundation suite's `request-uri-fetched-twice` test failed on that.)

A QR code is valid for the transaction's lifetime (`transactionTtlSeconds`, 300 s by default); after that it is an unknown transaction. If a login fails, the user is told one of four things only: `declined`, `not_accepted`, `expired` or `technical`. The specific check is in the audit log (`WALLET_LOGIN_SUCCESS` / `WALLET_LOGIN_FAILURE`) and the server log, never in the browser.

!!! note "Cross-device QR and the ARF"
    Separate EUDI ARF guidance recommends that conformant EU wallets do not support redirect-based cross-device flows, and points to the W3C Digital Credentials API instead. The EU reference wallet (2026.10.43-Demo) does accept Helix's cross-device request; whether national wallets will is still open. Helix offers the [Digital Credentials API](#the-w3c-digital-credentials-api) as an opt-in per connection, with the QR code and the wallet link kept as the fallback.

## Proxies, host names and rate limits

The wallet endpoints are rate limited per client IP, in memory on each replica:

| Endpoints | Burst | Refill | Properties |
| --- | --- | --- | --- |
| `/oid4vp/**` (any method) | 120 | 120 / minute | `helix.ratelimit.wallet.burst`, `helix.ratelimit.wallet.refill-per-minute` |
| starting a wallet login (`GET /broker/<wallet alias>`) | 30 | 30 / minute | `helix.ratelimit.wallet-start.burst`, `helix.ratelimit.wallet-start.refill-per-minute` |

Behind an ingress or load balancer, list it in `HELIX_TRUSTED_PROXIES`, or every phone and browser shares the proxy's one budget. Phones reach the host of `IDP_BASE_URL`, which is always allowed; a wallet demo on another host name needs that host in `HELIX_ALLOWED_HOSTS`. See [Security hardening](../operations/security.md#reverse-proxies-and-host-names).

## Transaction store and multiple replicas

Each wallet login's in-flight state (nonce, decryption key, browser binding, request, lifetime) lives in a transaction store shared by every replica, so a cross-device login whose wallet requests land on another pod still works. **No sticky sessions are needed.**

| Store | Used when |
| --- | --- |
| **PostgreSQL** (table `wallet_transaction`) | the session store is `queue`, or neither the session nor the token store is Redis |
| **Redis** | the session store is `redis`, or the token store is `redis` and the session store is not `queue` |
| **In memory** | only when you set `HELIX_WALLET_TRANSACTION_STORE=memory` — one replica, development and tests |

`helix.wallet.transaction-store` (env `HELIX_WALLET_TRANSACTION_STORE`: `jdbc`, `redis` or `memory`) overrides the choice. A server with neither Redis nor a database that was not asked for `memory` refuses to start. Both shared stores make every transition atomic and single-use and keep the request ids, response id, decryption key and verified identity encrypted with `DB_ENCRYPTION`. PostgreSQL deletes expired rows every `helix.wallet.transaction-sweep-seconds` (default 60). Expiry uses the application clock, so keep the replicas' clocks in sync (NTP).

**Configuration changes reach every replica without a restart.** Every identity-provider save or delete (admin API, console, realm import) bumps the realm's row in `idp_config_version`. Each replica polls it every `helix.federation.store.poll-interval-seconds` (env `HELIX_FEDERATION_STORE_POLL_INTERVAL_SECONDS`, default `10`, `0` turns polling off) and reloads the realms that changed. This needs `HELIX_FEDERATION_STORE_ENABLED` (default `true`). After restoring the database from a backup, restart every replica: a replica only reloads a realm whose version went up.

## The W3C Digital Credentials API

OpenID4VP 1.0 Appendix A defines how a verifier's web page asks for a credential through the browser itself (`navigator.credentials.get` with a `digital` request). The browser and the operating system show the user which wallets can answer and who is asking, and hand the answer back to the same page; cross-device works through the platform (a phone nearby). HAIP 1.0 §5.2 makes it the HAIP way to present over the web.

!!! warning "Status: Preview — not yet tested with a real wallet"
    The Digital Credentials API path has **not** been run against a real wallet. It is covered by unit tests against the spec's example vector and by an end-to-end test in which the project's own test wallet plays both the browser and the wallet. An attempt to repeat the reference-wallet emulator run over the DC API stopped before it started: the emulator image used ships a Chrome version that predates the API.

**Turning it on.** Per connection, off by default (console: **Policies → Digital Credentials API**):

| Key | Default | Meaning |
| --- | --- | --- |
| `dcApi.enabled` | `false` | offer **Continue with your wallet** on the wallet page, in browsers that support the API |
| `dcApi.request` | `signed` | `signed` (`openid4vp-v1-signed`: a JWS by the access certificate, with `expected_origins`) or `unsigned` (`openid4vp-v1-unsigned`); prefer signed |
| `dcApi.expectedOrigins` | empty | JSON array of the web origins the page is served on, written as a browser reports them (`https://login.example.eu`, no path, no trailing slash); at most 10; empty means the origin of `IDP_BASE_URL` |

The response is `dc_api.jwt` (encrypted to the transaction's own key) whenever `responseMode` is `direct_post.jwt`, and plain `dc_api` only with the dev-only `direct_post`. The QR code and the wallet link stay on the page as the fallback.

**Origins come from configuration only**, never from the request. Each expected origin must be on a host the server answers for: the host of `IDP_BASE_URL` or one listed in `HELIX_ALLOWED_HOSTS`. An SD-JWT VC presentation must carry `aud = origin:<expected origin>`; an mdoc DeviceAuth must verify over the `OpenID4VPDCAPIHandover` SessionTranscript of an expected origin. A presentation made for the redirect flows is refused over the DC API, and the other way round.

**Protections.** The two page endpoints (`POST /realms/{realm}/broker/{alias}/wallet/dc-api/request` and `…/dc-api/response`) need the browser session that started the login and the CSRF header, take strict JSON of at most 256 KiB (`helix.wallet.max-request-bytes`), and claim the transaction atomically: a second post, or a post after a wallet already fetched the QR or same-device request, is refused. Refusals are audited as `WALLET_LOGIN_FAILURE` with `invocation=dc_api`.

**Not supported:** the ISO `org-iso-mdoc` protocol (ISO 18013-7 Annex C; deferred, see [Transaction confirmation](wallet-transaction-confirmation.md#not-in-this-release)) and native apps as callers. Multi-signed requests, `transaction_data` and `verifier_info` are supported as Preview: see [Transaction confirmation](wallet-transaction-confirmation.md).

## Verify-only (step-up)

Some applications do not need the wallet to *sign the user in*; they need proof, for a user who is already signed in, that their own wallet confirms it is them (a step-up to eIDAS high), or a verified detail such as "18 or over". A wallet connection with `subject.strategy=NONE` does exactly that.

**Status: Preview** — new in 1.0.0-rc.11 (see the [limitations](#limitations) below for what is not in this release yet).

**What it never does.** It never signs anyone in (it is not on the login page, and `/realms/{realm}/broker/{alias}` refuses to start it), never creates an account, and by default never links a wallet (the account console neither lists nor links it) and stores nothing on the account. Two opt-in settings change the last two: binding an unlinked wallet by matching profile attributes, and keeping verified attributes on the profile. See [Wallet step-up and profile](wallet-step-up-and-profile.md) (Preview).

### Set it up

1. Keep (or create) the realm's normal wallet **sign-in** connection. Users link their wallet with it, as [above](#account-linking).
2. Create a second wallet connection with `subject.strategy=NONE` (console: **Policies → Verify only (step-up)**):

    | Key | Meaning |
    | --- | --- |
    | `verify.linkedProvider` | **Required.** The alias of the sign-in connection of step 1, in the same realm. A presented wallet must be linked to the signed-in user *there*. Its `minimumLoa` may not be lower than the linked connection's (`400`) |
    | `pid.requestedClaims` | What the wallet is asked for. Keep it small. The administrative number and issuing country are always asked for optionally, to bind the wallet; they are never released |
    | `verify.releasedClaims` | JSON array of what goes to the application: requested claims this verifier maps (`given_name`, `family_name`, `birthdate`, `nationalities`, `place_of_birth`, `address.*`, `issuing_country`, `issuing_authority`) or `age_over_NN` (1–150, worked out from the verified birth date, which must be requested). **Default: nothing**: the step then only proves the wallet and raises the level of assurance |

3. Add the step **EU Digital Identity Wallet verification** (`eudi-wallet-verify`, setting `providerAlias` = the verify-only connection) to a sign-in [flow](../authentication/flows.md) after the first factor, and bind that flow to the [application](../manage/applications.md). A flow save without `providerAlias` is refused (`400`).

!!! warning "Password logins need the flow engine"
    A login that starts with a password (or a magic link) runs flow steps only with `helix.flow-engine.enabled=true` (`HELIX_FLOW_ENGINE_ENABLED`, default `false`); a federated first factor always runs them. With the engine off, nothing is skipped silently: such a login whose flow has the wallet step is refused (`/login?error=flow`), saving or binding such a flow is a `400`, and the console shows a banner. `GET /admin/realms/{realm}/flow-engine` tells you which applies.

Validation (`400 {message, fieldErrors}`): `NONE` needs `verify.linkedProvider` (another alias), refuses `subject.acknowledgeWeakSubject=true`, and accepts only the release entries above; `verify.*` without `NONE` is refused. An application's single sign-on setting cannot name a verify-only connection.

### What the application gets

After the first factor, the step sends the browser to the usual wallet page (the copy says "Confirm", not "Sign in"). The presentation is verified exactly like a login. Then the wallet must be bound to **this** user through the linked connection's links: the administrative number when linked (a number linked to anyone else is refused), otherwise the attribute tuple when the linked connection accepts weak subjects. A wallet that is not linked at all, or is another person's, is refused, with **Try again**.

On success the login's `acr` becomes `http://eidas.europa.eu/LoA/high`. The released claims go into the **ID token** of the authorization request the step started for, as an OIDC Identity Assurance-style `verified_claims` object, **only if that request asked for the `verified_claims` scope** (a standard client scope, seeded in every realm; register it on the client). Without the scope the step still raises `acr`, and nothing is released:

```json
"acr": "http://eidas.europa.eu/LoA/high",
"verified_claims": {
  "verification": {"trust_framework": "eidas", "assurance_level": "high",
                   "time": "2026-10-15T10:00:00Z"},
  "claims": {"age_over_18": true, "nationalities": "DE,NL"}
}
```

- `assurance_level` is the OpenID Connect for Identity Assurance value `high` (before 1.1.0-rc.1 it was the LoA URI). An application with a [verified-claims policy](../integration/verified-claims.md) gets its policy's release instead, from this step's verification, in userinfo by default.
- `verified_claims` is pinned to that one authorization request for 5 minutes. It is in the ID token of the code exchange only, never in a refreshed ID token, userinfo or an access token, and nothing is persisted on the account. Protocol mappers cannot set it.
- **Pushed authorization requests (PAR):** a PAR client sends only `client_id` and `request_uri` on the front channel, so no release is pinned: a PAR client gets the step-up `acr` but **no** `verified_claims`.
- **Stepping up an existing session:** an application sends `acr_values=http://eidas.europa.eu/LoA/high`. When the session's `acr` does not satisfy it, the user signs in again once for that request and the application's flow (with the wallet step) runs. `acr_values` stays voluntary: if it is still not met (and always with `prompt=none`), the code carries the lower, actual `acr`. **Relying parties must check `acr` in the ID token.** Once raised, `acr` stays for the SSO session until the next login, unless the connection has a [step-up lifetime](wallet-step-up-and-profile.md#step-up-lifetime). When the client's flow has a top-level REQUIRED wallet step, a signed-in user [steps up without the first factor](wallet-step-up-and-profile.md#step-up-without-the-first-factor) (Preview).
- **Audit:** `WALLET_VERIFY_SUCCESS` (released claim **names** only) and `WALLET_VERIFY_FAILURE` (with a reason such as `BINDING_MISMATCH` or `SUBJECT_UNAVAILABLE`). No PID value is audited or logged.

**Not in this release:** a realm-wide default step-up lifetime; keeping attributes from a wallet *sign-in* connection; step-up without the first factor for the essential-claims step-up and for PAR requests. The step-up lifetime, step-up without the first factor, binding by matching attributes and keeping verified attributes are on [Wallet step-up and profile](wallet-step-up-and-profile.md). `verified_claims` in userinfo and access tokens, the OIDC `claims` parameter, discovery metadata and the claims of other credentials are released per application: see [Verified data for applications](../integration/verified-claims.md). Verifying attributes without a sign-in is the [verification API](../integration/verification-api.md). Having the user confirm a transaction is [transaction confirmation](wallet-transaction-confirmation.md).

## Interop: the EU reference wallet

On 2026-10-15 the project tested the verifier against the **EU Digital Identity Wallet reference app** for Android (`eudi-app-android-wallet-ui` 2026.10.43-Demo, the released APK, `demo` flavour) on an Android 15 emulator, with PIDs from the EU reference **test** issuer (`issuer.eudiw.dev`):

| Flow | SD-JWT VC PID | mdoc PID |
| --- | --- | --- |
| Same-device | Pass | Pass |
| Cross-device (QR) | Pass (QR content handed to the wallet by an Android intent, not the camera) | Pass (same) |

Every run used the defaults (`x509_hash`, `haip-vp://`, `direct_post.jwt`, issuer revocation `HARD_FAIL`, status list `FAIL_CLOSED`); no Helix change was needed. What to know if you repeat it:

- **The released wallet only talks to verifiers on the EU test list of access-certificate CAs.** A self-made access certificate does not work with it. The EU's Testing Relying Party Registration service (`https://registry.serviceproviders.eudiw.dev`) issues test access certificates under that list.
- **`x509_san_dns` was not tested:** the test registrar's certificates have no dNSName. Use `x509_hash` (the default) with them.
- **Declining sends nothing.** When the user taps Cancel, the reference wallet posts no error response, so the page waits until the transaction expires.
- **Captured presentations are test vectors.** Four of them (both formats, both flows), with their request objects and the verifier's per-transaction decryption keys, are verified offline on every build with the production code, including mdoc DeviceAuth over the real OpenID4VP SessionTranscript and the SD-JWT VC Key Binding JWT.

No **national** wallet has been tested yet. The full record, versions and steps are available to licensed customers from KubeDNA B.V. ([contact@helixiam.com](mailto:contact@helixiam.com)) on request.

## OpenID Foundation conformance suite run

The verifier was run against the OpenID Foundation conformance suite (version 5.3.3, revision `f0133e9`) on 2026-10-15, with the suite playing the wallet. **This is a self-run of the open-source suite, not an OpenID Certification**; certification is granted by the OpenID Foundation and was not requested.

Covered: the HAIP plan (`oid4vp-1final-verifier-haip-test-plan`) and the plain OpenID4VP 1.0 plan; `dc+sd-jwt` and `mso_mdoc`; `x509_hash` and `x509_san_dns`; `direct_post.jwt` (and `direct_post` in `dev`); `request_uri` with GET and POST; same-device and cross-device. Every positive test signed the linked account in, and every negative test was refused by the check it targets.

Result of the re-run after the fixes: **32 pass, 30 pass (review), 2 fail, 4 skipped**, 16 with a warning. "Pass (review)" means every check passed and the suite waits for a person to look at the uploaded evidence. The two failures and the warnings are justified in the record:

- the HAIP cross-device happy flow fails a check that a `redirect_uri` is returned to the wallet's POST, which HAIP §5.1 requires only for same-device logins;
- the warnings come from the suite's nonce-entropy heuristic, which flags 128-bit base64url nonces; an issue for the suite is drafted.

The first run also failed `request-uri-fetched-twice`; that was fixed (request objects can now be [fetched again until the response](#same-device-and-cross-device-flows)). Results per plan and module, logs and the harness to repeat the run are available to licensed customers from KubeDNA B.V. ([contact@helixiam.com](mailto:contact@helixiam.com)) on request.

## When a realm is deleted

Helix has **no** realm-delete operation in the console or the admin API. If a realm's `realm_config` row is removed (for example by operator SQL), a database trigger (Flyway `V85`) deletes that realm's wallet pepper, verifier access keys, in-flight wallet transactions and trusted list sources in the same transaction, so a realm created again under the same id starts with none of them instead of inheriting the old realm's pepper and access key. Wallet account links are removed with the linked **user**, not with the realm.

## Limitations

- **Beta** (core verifier), **Preview** (Digital Credentials API, verify-only), as above. The OpenID Foundation suite run was a self-run, not a certification.
- **No national wallet tested yet.** Only the EU reference wallet (test issuer, emulator), with the QR content passed by intent rather than the camera, and without `x509_san_dns`.
- **Digital Credentials API not yet run against a real wallet.**
- **Trusted lists:** signer rotation (the LOTL pivot) and LoTE pointers are tested with generated lists only, sources are not exported or imported with a realm, and no production EU list of PID providers is published yet. See [Trusted lists](trusted-lists.md).
- **Other credentials always come with the PID**: every accepted combination must include it. OpenID4VP has no proof that credentials bound to different keys are in one wallet. See [Credentials beyond the PID](wallet-credentials.md).
- **mdoc `deviceMac` is refused**; only `deviceSignature` holder binding is accepted.
- **No HSM or KMS** for the verifier access key.
- **One level of assurance**: a wallet login (or verification) always asserts `http://eidas.europa.eu/LoA/high`.
- **Verify-only:** no `verified_claims` for PAR clients, no realm-wide default step-up lifetime, and a password-first login needs the flow engine. See [Verify-only](#verify-only-step-up).
- A user who walks away from a post-login step-up leaves **no audit event** for that login.

## Try it: the sandbox demo

HelixIAM's sandbox relying party has a walkthrough that runs the whole flow on your machine: both credential formats, cross-device QR, same-device, and linking a wallet to an existing account. It seeds a `wallet-demo` realm and stands in for a phone with a test-wallet CLI. The sandbox and the test wallet are available to licensed customers from KubeDNA B.V. ([contact@helixiam.com](mailto:contact@helixiam.com)) on request.

!!! danger "Development and test only"
    The test wallet, its CA and every key it writes are throwaway material. They prove nothing about real-world identity and must never be imported into or trusted by a production deployment.

## See also

- [Identity providers](identity-providers.md) — the broker model, JIT provisioning, secrets
- [Wallet step-up and profile](wallet-step-up-and-profile.md) · [Trusted lists](trusted-lists.md) · [Credentials beyond the PID](wallet-credentials.md) · [Transaction confirmation](wallet-transaction-confirmation.md) · [DIDs](dids.md) · [Credential issuance](credential-issuance.md)
- [Verified data for applications](../integration/verified-claims.md) · [Verification API](../integration/verification-api.md)
- [eIDAS](eidas.md) · [DigiD](digid.md) · [eHerkenning](eherkenning.md) — the SAML eID connectors (separate from the wallet)
- [Account console](../integration/account-console.md) — where users link and unlink a wallet
- [Terraform provider](../integration/terraform.md) — manage a wallet connection as code with `helix_wallet_identity_provider`
- [Authentication flows](../authentication/flows.md) — where the `eudi-wallet-verify` step goes
- [Upgrading to rc.8 / rc.9](../operations/upgrade-rc8-rc9.md)
