Skip to content

Credential selection when multiple credential from wallet match #4067

Closed
5 of 5 issues completed
Closed

Description

@reinkrul

Problem

When a wallet contains multiple credentials of the same type, the Nuts node needs a way for the EHR to specify which one to select:

  • PatientEnrollmentCredential: one per patient in the wallet
  • HealthcareProviderTypeCredential: one per organization type (e.g. pharmacy and hospital)

The current PD matcher picks the first match, which is non-deterministic.

Related: #3993

Solution: credential_selection (named parameters)

A new optional credential_selection parameter on the request-service-access-token endpoint. The EHR provides simple key-value pairs mapping PD field IDs to expected values:

{
  "scope": "medicatieoverdracht-gf",
  "authorization_server": "https://verifier.example.com",
  "credential_selection": {
    "patient_id": "123456789"
  }
}

The PD author controls what is selectable by assigning id values to constraint fields (already done for introspection claims). The EHR only needs to know the field name and value — no JSON paths, no query language.

See the full PRD for design details, trade-offs, and architecture.

Status

Sub-issue Description PR Status
#4088 CredentialSelector extension point in PD matcher #4098 Open
#4120 Selection CredentialSelector (filter by field ID values) #4121 Open
#4090 Wire credential_selection through API to presenter #4122 Open

Dependency graph

#4088 → #4120 → #4090

Closed / superseded

Issue Reason
#4089 DCQL selector — superseded by #4120 (named params approach)
#4087 DCQL parser — kept open for potential future use, not needed for current approach

Decision trail

The comments below document the evolution from DCQL-based credential queries to the simpler named-parameter approach. Key decisions:

Activity

  1. stevenvegt commented on Mar 20, 2026

    @stevenvegt
    Member

    I was under the assumption DCQL could be used, reading section 6.3, you can provide a claims query which consists of a path of the claim and possible values.

    From appendix D, this example shows selecting a person with last name "Doe" living in either 90210 or 90211.

    {
      "credentials": [
        {
          "id": "my_credential",
          "format": "dc+sd-jwt",
          "meta": {
            "vct_values": [ "https://credentials.example.com/identity_credential" ]
          },
          "claims": [
              {
                "path": ["last_name"],
                "values": ["Doe"]
              },
              {"path": ["first_name"]},
              {"path": ["address", "street_address"]},
              {
                "path": ["postal_code"],
                "values": ["90210", "90211"]
              }
          ]
        }
      ]
    }
    
  2. reinkrul commented on Mar 20, 2026

    @reinkrul
    MemberAuthor

    Nice! I think I didn't read it properly.

  3. stevenvegt commented on Mar 20, 2026

    @stevenvegt
    Member

    PRD: Credential Selection via Named Parameters

    Problem Statement

    When a wallet contains multiple credentials of the same type (e.g., multiple PatientEnrollmentCredentials for different patients, or multiple HealthcareProviderTypeCredentials for different organization types), the Nuts node has no mechanism for the EHR to specify which credential to select. The current Presentation Definition (PD) matching picks the first match, which is non-deterministic when multiple credentials of the same type exist.

    Solution

    Add a credential_selection parameter to the request-service-access-token endpoint (auth v2) that allows the EHR to specify which credential to use via simple key-value pairs. Each key maps to a field id already declared in the Presentation Definition's input descriptor constraints. The value narrows the match to credentials where that field equals the given value.

    A configurable CredentialSelector is injected into the PD matcher's matchConstraints function. When credential_selection is provided, a parameter-based selector filters the candidates by matching field values. When not provided, the default selector (first match) preserves current behavior. The Match public API and return types remain unchanged — one credential per input descriptor.

    The selection must narrow to exactly one credential per input descriptor. Zero matches → error (soft failure if submission requirements allow it, e.g. min: 0). Multiple matches → error. The EHR must provide values specific enough to select one credential.

    How It Works

    PDs already declare constraint fields with optional id properties. These IDs are used for extracting values into the access token via ResolveConstraintsFields, which builds a flat map[string]any (field ID → value). This flat map is stored in AccessToken.InputDescriptorConstraintIdMap and returned as top-level claims in the introspection response. Field IDs are already validated to be globally unique across all PDs in the WalletOwnerMapping — duplicates cause a ServerError (see resolveInputDescriptorValues in s2s_vptoken.go).

    Example PD field:

    {
      "id": "patient_id",
      "path": ["$.credentialSubject.hasEnrollment.patient.identifier.value"],
      "filter": { "type": "string" }
    }

    The credential_selection parameter reuses these field IDs. When the EHR provides { "patient_id": "123456789" }, the selector finds all candidates matching the input descriptor's constraints, then further narrows to those where the patient_id field equals "123456789".

    The PD author controls what is selectable by assigning id values to constraint fields. The EHR only needs to know the field name and the value — no JSON paths, no query language syntax.

    User Stories

    1. As an EHR developer, I want to specify which patient's PatientEnrollmentCredential to present, so that the correct patient context is established in the access token request.
    2. As an EHR developer, I want to specify which HealthcareProviderTypeCredential to present, so that the correct organization type is used when my organization has multiple classifications.
    3. As an EHR developer, I want to only provide credential_selection for ambiguous credential types, so that I don't need to specify selection criteria for credentials that are already unambiguous.
    4. As an EHR developer, I want a clear error when my selection criteria match zero credentials in the wallet, so that I can diagnose misconfiguration or missing credentials.
    5. As an EHR developer, I want a clear error when my selection criteria still match multiple credentials, so that I know my values are not specific enough.
    6. As an EHR developer, I want a simple key-value interface for credential selection, so that I don't need to learn a query language or know internal credential JSON paths.
    7. As an EHR developer, I want to continue providing self-attested credentials via the existing credentials array alongside credential_selection, so that both mechanisms work together.
    8. As an EHR developer, I want the existing behavior (PD picks first match) to be preserved for input descriptors where I don't provide selection criteria, so that this change is backward compatible.
    9. As a Nuts node operator, I want credential selection errors to be descriptive, so that I can help EHR vendors troubleshoot integration issues.

    Implementation Decisions

    API Design

    The ServiceAccessTokenRequest gains one new optional field:

    • credential_selection: An object mapping PD field ids to expected values. Each key MUST match a field id declared in a PD input descriptor's constraints. Field IDs are already guaranteed unique across PDs (enforced by resolveInputDescriptorValues duplicate detection).

    Example request:

    {
      "scope": "medicatieoverdracht-gf",
      "authorization_server": "https://verifier.example.com",
      "credential_selection": {
        "patient_id": "123456789"
      }
    }

    Compare to the previous DCQL approach (now superseded):

    {
      "credential_query": [
        {
          "id": "id_patient_enrollment",
          "claims": [
            {
              "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"],
              "values": ["123456789"]
            }
          ]
        }
      ]
    }

    CredentialSelector Architecture

    A CredentialSelector strategy is injected into the PD matcher's matchConstraints function. This is the same injection point used by the DCQL selector (#4089). See approach comment.

    matchConstraints collects all matching VCs per input descriptor (removes the break on first match), then calls the CredentialSelector to pick one. The Match return type and Candidate struct remain unchanged.

    type CredentialSelector func(inputDescriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error)
    • Default selector: picks the first match (preserves current behavior)
    • Selection selector: for each candidate, resolves the PD fields referenced by credential_selection keys, keeps only candidates where all field values match the given values, errors on zero or multiple remaining candidates

    The selection selector follows the same factory pattern as NewDCQLSelector in vcr/pe/selector.go (branch feature/4089-dcql-selector): validate selection keys against PD field IDs at construction time, return a CredentialSelector closure. The wiring through buildSubmission in presenter.go (branch feature/4090-api-credential-query) is also directly reusable — replace credentialQueries []dcql.CredentialQuery with credentialSelection map[string]string.

    Error handling in matchConstraints:

    • ErrNoCredentials from the selector is treated as a soft failure (nil selection), allowing submission requirements (e.g., pick rules with min: 0) to evaluate whether zero fulfilled descriptors is acceptable.
    • ErrMultipleCredentials and other errors are hard failures.

    Trade-offs

    This approach is simpler but more constrained than a full query language:

    credential_selection (chosen) credential_query (DCQL, superseded)
    Caller complexity Key-value pairs, no paths needed Must know JSON paths and DCQL syntax
    Query expressiveness Equality only, pre-declared fields Arbitrary paths, OR semantics
    PD author effort Add id to selectable fields (often already done for introspection) None (queries are self-contained)
    Error surface Wrong key name Wrong path, wrong ID, wrong structure

    Why the simpler approach is sufficient:

    • Real-world use cases are equality matches on known fields (which patient, which organization type).
    • The PD already declares relevant fields with ids for access token value extraction — reusing these avoids duplication.
    • Field IDs are already guaranteed unique across PDs and surfaced as flat claims in introspection — the namespace is consistent.
    • If a richer query language is needed in the future, it can be added as a separate parameter without breaking credential_selection.

    Modules

    1. CredentialSelector in PD matcher (Refactor PD matching to return all candidates per input descriptor #4088, modify vcr/pe): Inject CredentialSelector into matchConstraints. Collect all candidates per input descriptor, then call selector. Default selector picks first match. Soft-fail for ErrNoCredentials. Status: PR #4088: CredentialSelector extension point in PD matcher #4098 open.
    2. Selection CredentialSelector (new issue, reuses code from feature/4089-dcql-selector): Implementation that resolves PD field ids against candidates and filters by the credential_selection values. Same factory pattern as NewDCQLSelector — validate keys at construction, return selector closure. Errors on zero or multiple matches.
    3. API layer + OpenAPI spec (Add credential_selection to access token request API #4090, modify auth/api/iam, reuses wiring from feature/4090-api-credential-query): Add credential_selection to ServiceAccessTokenRequest, propagate through RequestRFC021AccessToken → BuildSubmission → presenter.buildSubmission → builder.SetCredentialSelector. Same call chain as the DCQL approach, with map[string]string instead of []dcql.CredentialQuery.

    Superseded modules:

    Modules kept open for future consideration:

    Reusable Code

    Branches feature/4089-dcql-selector and feature/4090-api-credential-query contain implementation that maps closely to the new approach:

    Branch File What to reuse
    4089 vcr/pe/selector.go Factory pattern: validate IDs at construction, return CredentialSelector closure. Replace dcql.Match with field-value matching.
    4089 vcr/pe/selector_test.go Test structure: fallback, single match, zero matches, multiple matches, ID validation, multi-descriptor. Adapt assertions.
    4090 vcr/holder/presenter.go buildSubmission wiring: create selector if selection provided, call builder.SetCredentialSelector. Replace []dcql.CredentialQuery param with map[string]string.
    4090 vcr/holder/interface.go BuildSubmission signature change. Replace credentialQueries param with credentialSelection.
    4090 auth/api/iam/api.go API handler: parse selection from request, propagate to client. Simpler than DCQL parsing.
    4090 docs/_static/auth/v2.yaml OpenAPI spec structure. Replace credential_query schema with credential_selection object.

    Testing Decisions

    • CredentialSelector (Refactor PD matching to return all candidates per input descriptor #4088): Unit tests for default selector (first match), selector called with all candidates, soft-fail for ErrNoCredentials with min: 0 submission requirements. Existing PD matching tests pass unchanged. Done in PR #4088: CredentialSelector extension point in PD matcher #4098.
    • Selection CredentialSelector (new): Adapt test structure from feature/4089-dcql-selector branch. Unit tests for: single match success, zero matches error (ErrNoCredentials), multiple matches error (ErrMultipleCredentials), selection key not matching any PD field ID errors, multiple selection keys (AND semantics), fallback to default selector for descriptors without matching selection keys, interaction with submission requirements.
    • API layer (Add credential_selection to access token request API #4090): End-to-end tests for the full request flow with and without credential_selection. Adapt from feature/4090-api-credential-query e2e test.

    Out of Scope

    • Replacing PD with DCQL entirely: Longer-term goal. DCQL currently lacks some PD features we depend on, notably regex-based value extraction for token introspection.
    • Verifier-side changes: The verifier's PD and token validation are unchanged. credential_selection is purely a requestor-side wallet selection mechanism.
    • Complex query support: Range queries, OR semantics, pattern matching on selection values. Can be added later if needed.
    • Migration of existing PD configurations: Existing PDs continue to work unchanged.

    Sub-issues

    Dependency graph

    #4088 ──> #4120  ──> #4090
    

    Changelog

  4. stevenvegt commented on Mar 23, 2026

    @stevenvegt
    Member

    Does the PEX specification allow for multiple results? If not, does this make our PEX broken? Is this a problem if we always perform the post filtering with the default filter (latest)? Perhaps provide a param to the PEX selector with either a set of post filters or a boolean to return all results or only one.

  5. stevenvegt commented on Mar 24, 2026

    @stevenvegt
    Member

    Updated approach: inject selection strategy into PD matcher instead of returning multiple candidates

    After reviewing the PEX specification, we confirmed that the descriptor_map in a Presentation Submission maps one credential per Input Descriptor. While the spec doesn't explicitly prohibit multiple matches, the entire submission model is designed around a 1:1 relationship.

    Returning multiple candidates from the matcher would change its return type and potentially break callers. Instead, we inject a credential selection strategy into the existing matchConstraints function:

    1. matchConstraints collects all matching VCs per input descriptor (removes the break on first match)
    2. A CredentialSelector function picks one credential from the candidates
    3. The Candidate struct and Match return type remain unchanged — one VC per input descriptor

    The default CredentialSelector picks the first match (preserving current behavior). When a credential_query is provided, a DCQL-based selector filters candidates and applies the match_policy.

    This keeps the Match public API stable, avoids a separate filter chain layer, and puts the selection logic where it belongs — inside the matcher that already knows about input descriptors and candidates.

  6. stevenvegt commented on Mar 24, 2026

    @stevenvegt
    Member

    Decision: drop match_policy, DCQL filter must narrow to exactly one credential

    After reviewing the DCQL multiple field and our PEX submission constraints, we've decided to drop the match_policy parameter from the API.

    The PEX specification requires one credential per Input Descriptor in the descriptor_map. This means regardless of how many credentials the DCQL filter matches, we can only submit one. The match_policy options we considered ("error", "latest", "all") are either the only safe choice or incompatible with PEX:

    • "error" — the only deterministic option. The EHR must write a query specific enough to match exactly one credential.
    • "latest" — non-deterministic, depends on wallet state rather than explicit selection criteria. If the EHR cares which credential to use, they should be specific in their query.
    • "all" — incompatible with PEX 1:1 submission model.

    New behavior: the DCQL credential_query must narrow to exactly one credential per input descriptor. Zero matches → error. Multiple matches → error. No configuration needed — the EHR writes a precise query.

    This also means we don't need the DCQL multiple field, since our PEX submission is always 1:1.

  7. added a commit that references this issue on Apr 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions