Discuss a project
menu

Every alternative in a DCQL query is a request

Why a verifier should check every credential and claim alternative in an OpenID4VP DCQL query against a purpose policy before the request reaches an EUDI Wallet, and how to do it in a backend and in CI.

An OpenID4VP verifier tells a wallet what it wants with a DCQL query. The query can offer alternatives: several acceptable credentials, optional extras, several acceptable sets of claims. Teams usually review the option they expect a wallet to pick. The wallet is free to pick any of them.

That gap matters for data minimization. A service that only needs to know a user is over 18 can send a query that, on a less common path, asks for a birth date and a family name. The query is valid, the preferred path is fine, and a design review that read the first option would approve it.

We built eudi-disclosure-policy, an Apache 2.0 TypeScript library and CLI, to close that gap on the verifier side. This article explains the problem, how the library evaluates a query, where it fits in a verifier integration and what it deliberately leaves to other controls. Protocol references are to the final OpenID for Verifiable Presentations 1.0 specification, published 9 July 2025.

option order is a preference, not a promise

Section 6.4 of OpenID4VP defines how a wallet answers a DCQL query. With credential_sets, the wallet satisfies every required set with exactly one of its options, and may also satisfy optional sets. With claim_sets, it returns one of the listed claim combinations. In both cases the order of the options expresses the verifier's preference only.

Here is a query that passes a parser and passes a check of its first option:

{
  "credentials": [
    {
      "id": "proof",
      "format": "dc+sd-jwt",
      "meta": { "vct_values": ["urn:example:proof-of-age:v1"] },
      "claims": [{ "path": ["age_over_18"] }]
    },
    {
      "id": "pid",
      "format": "dc+sd-jwt",
      "meta": { "vct_values": ["urn:example:pid"] },
      "claims": [{ "path": ["birth_date"] }, { "path": ["family_name"] }]
    }
  ],
  "credential_sets": [{ "options": [["proof"], ["pid"]] }]
}

A wallet that holds a PID but no proof-of-age credential can answer with the PID. The service learns a birth date and a family name to establish a yes or no fact. The second option is often added for a good reason: it keeps users without a dedicated age credential from failing the flow. It is still a request for identity data, and it has to be judged as one.

Optional credential sets carry the same risk. An optional set asking for resident_address next to a required age check is a request for the address, whether or not most wallets return it.

checking the worst selectable path

The library takes three inputs: the DCQL query you are about to send, a policy for one purpose, and an evaluation time you supply. The policy lists which credential types and claims that purpose may request, which claims are denied outright, and what to do in uncertain cases. Deny rules override allow rules.

{
  "schemaVersion": "1.0",
  "policyId": "example.age-verification",
  "policyVersion": "1.0.0",
  "purposeId": "age-verification-over-18",
  "credentials": [
    {
      "id": "proof-of-age",
      "format": "dc+sd-jwt",
      "credentialType": { "kind": "vct", "value": "urn:example:proof-of-age:v1" },
      "allowClaims": [{ "path": ["age_over_18"], "matching": "exact" }],
      "denyClaims": [{ "path": ["birth_date"], "matching": "exact" }],
      "disclosureProfile": { "mode": "selective", "knownMandatoryPaths": [] }
    }
  ],
  "defaults": {
    "unknownCredential": "deny",
    "unknownClaim": "deny",
    "unknownDisclosure": "indeterminate",
    "registrationEvidenceRequired": false,
    "permitValueConstraints": false,
    "permitHolderBindingDisabled": false,
    "permitMultipleCredentials": false
  }
}

Every option of every set is evaluated, so a request is judged by the worst element a wallet could select. Against this policy, the query above is denied with two findings. CREDENTIAL_SET_EXCESSIVE_OPTION points at /credential_sets/0/options/1: the PID option violates the policy while its sibling complies. CREDENTIAL_TYPE_NOT_ALLOWED points at the PID type, which no rule in the policy allows. Each finding carries a stable rule ID, a JSON pointer into the query and a suggested fix. Claim values never appear in the output.

Beyond the allowlist, the library reports query features that increase disclosure or linkability. A values filter, such as asking for age_over_18 only if it equals true, is reported as a probing risk, following the privacy considerations in section 15.4 of the specification. A request with holder binding disabled, several credentials where one would do, or an mdoc element marked intent_to_retain is flagged as well, unless the policy explicitly permits it. Paths are matched with the format's own rules: JSON claim paths for SD-JWT VC, and exact [namespace, element] pairs for mdoc, where a similar element name in another namespace is a different claim.

unknown is not allowed

A presentation can disclose more than the claims a query names. Some fields are always present in a credential, and some formats cannot disclose selectively. The library cannot inspect a credential that has not been issued yet, so the policy author describes each allowed type with a disclosure profile, including the fields that are always disclosed.

When that information is missing, the result is INDETERMINATE, not ALLOW. The same applies to unknown credential types and unsupported formats. The verdict is the most severe finding, in the order DENY, INDETERMINATE, WARN, ALLOW, and a separate coverage field tells an explicit violation apart from an incomplete analysis. For a privacy control, an answer of "we could not tell" should stop the request until someone fills the gap.

The library also does not infer meaning. A birth date does not satisfy an over-18 purpose because the age could be derived from it. Only explicit policy rules count.

where it sits in a verifier integration

Run the check where your service builds the DCQL query, before the query is signed into an authorization request. Load and validate policies at startup, so a broken policy stops a deployment rather than a single user flow:

import { evaluateRequest, validatePolicy } from '@su-engineering/eudi-disclosure-policy';

const policy = validatePolicy(ageVerificationPolicyJson);

export function preflight(dcql: unknown): void {
  const result = evaluateRequest({
    profile: 'openid4vp-1.0',
    evaluationTime: new Date().toISOString(),
    dcql,
    policy,
  });
  if (result.verdict === 'DENY' || result.verdict === 'INDETERMINATE') {
    throw new Error(result.findings.map((f) => `${f.ruleId} ${f.jsonPointer}`).join('\n'));
  }
}

A blocked request at this point is a defect in the service's query construction, not a user error. The core is synchronous and has no network, clock, file system or environment access, so the same check runs in Node.js, browsers and edge runtimes. Enforce it on the server that signs the request.

Most verifiers send a small, fixed set of queries. Those queries can be kept as JSON files next to their policies and checked on every pull request:

npx eudi-disclosure policy validate policies/age-verification.json
npx eudi-disclosure lint --query requests/age.json \
  --policy policies/age-verification.json \
  --evaluation-time 2026-01-01T00:00:00Z --strict

The CLI exits non-zero on DENY and INDETERMINATE, and on WARN with --strict. A fixed evaluation time keeps CI results reproducible. Pin the package version, because rule behaviour is versioned and an upgrade should be a reviewed change. The integration guide has a complete GitHub Actions job.

This turns the purpose policy into a reviewable artifact. The privacy or legal owner approves the policy file, engineering owns the queries, and a pull request that adds a fallback credential shows the policy decision it needs before it ships.

what it does not decide

An EUDI presentation passes several independent controls, and this library implements one of them. It answers whether your service should ask for this data for this purpose, according to the policy you approved.

It does not verify relying-party registration. Under Implementing Regulation (EU) 2024/2982, Annex II, a wallet compares a request with the relying party's registered attributes and warns the user when the request exceeds them. The library can compare a query with registration scope your code has already verified, and reports a request outside that scope. Fetching and verifying the registration itself stays with your integration.

It does not evaluate issuers' embedded disclosure policies, which the wallet enforces, and it does not replace the user's approval in the wallet. An ALLOW is never an authorization for a wallet to disclose. It is not a legal assessment either: whether a purpose is lawful, necessary and proportionate is decided before anyone writes the policy.

The current release is a 0.x preview. It supports OpenID4VP 1.0 Final with dc+sd-jwt and mso_mdoc, and every standard it implements or cites is pinned with a retrieval date in the repository's standards lock. SARIF output and interoperability fixtures are next on the roadmap.

what to put in a verifier project scope

For a team adding wallet presentations to an existing service, the preflight gives a concrete acceptance item. We would scope it like this:

  1. One policy per purpose. Age verification, onboarding and account recovery each get their own policy, approved by the person who owns that purpose.
  2. A query inventory. Every DCQL query the service can send is listed, stored or generated in a build step, and linted in CI.
  3. Failure cases as fixtures. A fallback to a PID, an optional address set, a value filter and an unprofiled credential type are kept as test requests with expected verdicts.
  4. Runtime enforcement. The signing path refuses DENY and INDETERMINATE, and logs rule IDs and pointers only.
  5. An owner for uncertainty. Someone is named to resolve INDETERMINATE results, usually by adding a disclosure profile for a credential type the team has reviewed.

The preflight sits alongside the other parts of a verifier integration. For replay protection and session binding, see OpenID4VP replay protection. For the surrounding architecture, see integrating an EUDI wallet with an existing customer platform.

The library is on npm and GitHub. Issues and fixtures from real verifier queries are welcome. If you are scoping a verifier and want help drawing the line between what a purpose needs and what a query asks for, our identity engineering practice works on exactly that. Tell us about the flow.

Working on something similar? Talk to us

← all writing