DEV Community

Krishna Yadav
Krishna Yadav

Posted on Originally published at krishnakky.com

EUDI Wallet Verifier in Spring Boot: OpenID4VP, SD-JWT VC

Illustration of a smartphone identity wallet sending a signed credential to a Spring Boot verifier service, with a shield, a trust list and a revocation check shown along the path

To accept an EU Digital Identity Wallet in Spring Boot, you build a verifier service that sends a signed OpenID4VP request, receives the SD-JWT VC presentation on a direct_post endpoint, and then checks the issuer signature, key binding, trust list and revocation status before returning a minimal yes or no.

Member states have to make a Digital Identity Wallet available to their citizens by the end of 2026. The duty to accept those wallets falls on relying parties in 2027, and payment providers and banks are named in that group through strong customer authentication and KYC rules, the same regulated corner I covered in my PSD2 and EU banking API guide. If your onboarding team still has wallet acceptance parked under "later", the calendar has stopped agreeing with that.

I spent a few weeks building a verifier in Spring Boot to find out what accepting a wallet credential costs an engineering team in practice. What follows is the wiring: how the request leaves, what comes back, which checks are mandatory, and where the specs leave the decision to you. There is no policy discussion here; the regulation is public. The one thing I will show that bit me is the iss and aud handling in the key binding check, because the verifier URL that looks right in a test environment is not necessarily the string a wallet signs over in production.

How I frame the verifier

A relying-party verifier is a small service that does four things in a row:

  1. Builds an authorization request that names the credential type and the exact attributes you want.
  2. Gets that request in front of a wallet, through a QR code on desktop or a deep link on a phone.
  3. Accepts the presentation the wallet posts back.
  4. Makes a deterministic accept-or-reject decision and writes down why.

The exchange runs on OpenID for Verifiable Presentations (OpenID4VP) and the credential format is SD-JWT VC. The wallet obtained its credential earlier through OpenID4VCI. You never speak OID4VCI as a verifier, but every field you validate was written at issuance by somebody else, so you have to know what that process produced.

Coming from payments, I treat it like a card-acquiring path with no stored cardholder data. It keeps no accounts and no copies of identity documents. A signed claim goes in, signatures and signers are checked, validity is checked, and a minimal result comes out. That shapes the architecture: short-lived sessions, almost no state, an audit trail, and no customer data store.

The flow end to end

Browser/App        Verifier (Spring Boot)         Wallet            Issuer / Trust infra
    | start KYC           |                          |                      |
    |-------------------->| create session+nonce     |                      |
    |   QR / deep link    | sign request object      |                      |
    |<--------------------|                          |                      |
    |---- open link ------------------------------->|                      |
    |                     |<-- fetch request object--|                      |
    |                     |--- signed JWT ---------->| user consents        |
    |                     |<-- direct_post (vp_token)|                      |
    |                     | verify issuer sig, KB-JWT, nonce               |
    |                     |-- trust list lookup ---------------------------->|
    |                     |-- status list fetch ---------------------------->|
    |<-- result (poll/SSE)|                          |                      |
Enter fullscreen mode Exit fullscreen mode

Two points catch people out. The phone running the wallet is not the browser that showed the QR code, so the answer cannot return through the browser. The wallet calls your backend directly (response_mode=direct_post, or direct_post.jwt when the body is encrypted), and the front end has to poll or subscribe for the result. And the request object is itself signed, so the wallet can check who is asking before the user sees a consent screen. Authentication runs in both directions.

Step one: ask for the smallest set of claims

The query language in OpenID4VP is DCQL, which lets you list credentials and claim paths you need. Selective disclosure is only useful if the verifier actually asks narrowly, and I resist the "request everything, we may need it later" instinct from product teams.

For a standard onboarding journey you usually need legal name and date of birth, and sometimes nationality or country of residence for sanctions screening and jurisdiction rules. Address and document numbers are separate decisions. Every extra attribute lands in your logs, backups and breach-notification scope, which is the practical meaning of data minimisation.

Minimal does not mean minimal at any price. KYC duties under AML rules usually require identifying the customer, verifying identity and, for many products, capturing date of birth and an address, so an over-18 assertion on its own is typically enough for age-gated services but not for opening a regulated account. Whether a given bank can onboard on fewer attributes is a question for its compliance function and its supervisor, not a default I can hand you. What I did was encode that decision as data: one profile per journey, each reviewed by compliance before it ships.

Here is the request builder with all helpers shown, so nothing is hidden. The session record keeps the nonce and the query and never any user data.

@Service
public class PresentationRequestService {

    private static final SecureRandom RANDOM = new SecureRandom();

    private final SessionStore sessions;           // Redis-backed, TTL 5 minutes
    private final RequestObjectSigner signer;      // signs with the key behind our x509 cert
    private final VerifierProperties props;

    public PresentationRequestService(SessionStore sessions,
                                      RequestObjectSigner signer,
                                      VerifierProperties props) {
        this.sessions = sessions;
        this.signer = signer;
        this.props = props;
    }

    public StartedSession start(KycProfile profile) {
        String sessionId = UUID.randomUUID().toString();
        String nonce = randomUrlSafe(32);
        String clientId = "x509_san_dns:" + props.hostname();

        Map<String, Object> dcql = Map.of(
            "credentials", List.of(Map.of(
                "id", "pid",
                "format", "dc+sd-jwt",
                "meta", Map.of("vct_values", List.of(props.pidVct())),
                "claims", profile.requiredPaths().stream()
                    .map(p -> Map.of("path", p))
                    .toList()
            ))
        );

        Map<String, Object> requestObject = new LinkedHashMap<>();
        requestObject.put("response_type", "vp_token");
        requestObject.put("client_id", clientId);
        requestObject.put("response_mode", "direct_post.jwt");
        requestObject.put("response_uri", props.baseUrl() + "/oid4vp/response/" + sessionId);
        requestObject.put("nonce", nonce);
        requestObject.put("dcql_query", dcql);
        requestObject.put("state", sessionId);

        sessions.save(new VerifierSession(sessionId, nonce, clientId, profile.name(),
                profile.requiredPaths(), Instant.now()));

        String jwt = signer.sign(requestObject);
        sessions.saveRequestJwt(sessionId, jwt);

        String link = "openid4vp://authorize?client_id=" + enc(clientId)
                + "&request_uri=" + enc(props.baseUrl() + "/oid4vp/request/" + sessionId);
        return new StartedSession(sessionId, link);
    }

    private static String randomUrlSafe(int bytes) {
        byte[] buf = new byte[bytes];
        RANDOM.nextBytes(buf);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
    }

    private static String enc(String v) {
        return URLEncoder.encode(v, StandardCharsets.UTF_8);
    }
}
Enter fullscreen mode Exit fullscreen mode

KycProfile.requiredPaths() returns something like [["family_name"], ["given_name"], ["birthdate"], ["nationality"]], since DCQL claim paths are arrays of path segments. Profiles live in versioned configuration, one for basic onboarding and one for higher-risk accounts. The profile name goes onto the session so an audit can show exactly which minimisation decision applied. A request profile is a compliance artifact: it sits in Git and goes through review.

Pay attention to the client_id prefix, and note that I now store the full prefixed value on the session. That is the detail behind my earlier remark. The wallet signs the key binding over the client_id exactly as it appeared in the request, prefix included. My first version compared the audience to the bare hostname, which is what I had in configuration, and the check was wrong for every real presentation. Keeping the prefixed string on the session removes the guesswork.

The identity itself comes from an X.509 certificate chain (a relying party access certificate) in the request object's header, and the client_id has to match a DNS name in its SAN. A registrar-linked authority in your member state issues that certificate, and it is why a wallet can show "Acme Payments is asking for these three attributes" instead of a bare URL.

One operational note from the signing side: the request-object key should not sit as a PEM on disk. I put it behind a PKCS#11 interface so the same RequestObjectSigner works against a software keystore in dev and an HSM in production, and signing latency stays a rounding error next to the user's consent tap.

Step two: receiving the presentation

The wallet calls your response_uri with a vp_token. With direct_post.jwt the whole response is a JWE encrypted to a key you published in the request, so a proxy or WAF log in between cannot leak the credential.

The controller stays deliberately thin. Validation belongs in a service you can unit test with fixture credentials.

@RestController
@RequestMapping("/oid4vp")
public class ResponseController {

    private final ResponseDecryptor decryptor;
    private final SdJwtVcVerifier verifier;
    private final SessionStore sessions;
    private final VerificationAudit audit;

    // constructor omitted

    @PostMapping(value = "/response/{sessionId}",
                 consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public ResponseEntity<Map<String, String>> receive(@PathVariable String sessionId,
                                                       @RequestParam("response") String jwe) {
        VerifierSession session = sessions.consume(sessionId)   // single use, atomic
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.GONE));

        VerificationOutcome outcome;
        try {
            Map<String, Object> payload = decryptor.decrypt(jwe);
            String vpToken = extractPresentation(payload, "pid");
            outcome = verifier.verify(vpToken, session);
        } catch (VerificationException e) {
            outcome = VerificationOutcome.rejected(e.reason());
        }

        audit.record(session, outcome);
        sessions.storeOutcome(sessionId, outcome.minimised());
        return ResponseEntity.ok(Map.of());   // wallet only needs a 200
    }
}
Enter fullscreen mode Exit fullscreen mode

sessions.consume has to be an atomic get-and-delete, implemented with Redis GETDEL. A wallet that retries after a network timeout, or an attacker replaying a captured response, must hit a 410 before any cryptography runs. This is the same idempotency thinking I described in transactional outbox and idempotent consumers for payment systems, applied to identity instead of payments.

That design has a cost worth naming. If the wallet's first attempt reaches you, you consume the session, and then the response is lost on the way back, the retry gets 410 and the user must restart. I accepted that: a restart costs the user one more scan, while a reusable session costs you a replay window. The related race is the TTL. With a five-minute TTL and a slow user on a bad connection, the session can expire between the wallet fetching the request object and posting the answer. I treat that as a normal outcome, return a distinct "expired" reason, and let the front end offer a fresh QR.

Step three: what an SD-JWT VC actually contains

A presentation is a tilde-separated string: the issuer-signed JWT, then zero or more disclosures, then a final key binding JWT (KB-JWT). A disclosure is a base64url-encoded array [salt, claim_name, claim_value]. The issuer JWT holds no values for hidden claims; it holds only _sd, a list of digests. When the user shares family_name, the wallet appends the matching disclosure, and you hash it and confirm the digest appears in _sd.

So you cannot decode the JWT and read the claims. A reader that skips the disclosure step sees an almost empty payload, and a sloppy one might accept it.

My checks, in order, with the reason for each:

  1. Parse strictly. Reject disclosures that match no digest, duplicate digests, and disclosures for claims you never asked for. An unrequested claim is not a free bonus. It means data is arriving outside your declared purpose.
  2. Validate the chain from the x5c header to a trust anchor taken from the EU trusted lists (below).
  3. Verify the issuer signature with the leaf certificate's key.
  4. Check vct against what you requested and exp/nbf against the clock, with a small skew allowance.
  5. Verify the KB-JWT. Its signature must verify under the key in the credential's cnf claim, and it must carry your nonce, your prefixed client_id as aud, a fresh iat, and an sd_hash over the exact presentation string you received.
  6. Check revocation status.

Step 5 proves the person holding the phone is the holder the credential was issued to. Skip it and a leaked SD-JWT becomes a bearer token for someone else's identity.

@Component
public class SdJwtVcVerifier {

    private static final Duration CLOCK_SKEW = Duration.ofSeconds(60);

    private final TrustAnchorProvider anchors;
    private final StatusListClient statusClient;
    private final KeyBindingVerifier keyBinding;
    private final Clock clock;

    public VerificationOutcome verify(String presentation, VerifierSession session)
            throws VerificationException {

        SdJwtPresentation p = SdJwtPresentation.parse(presentation);   // splits on '~'

        SignedJWT issuerJwt = p.issuerSigned();
        List<X509Certificate> chain = X5cChain.from(issuerJwt.getHeader());
        anchors.validate(chain);                                       // PKIX against trust list

        try {
            if (!issuerJwt.verify(new ECDSAVerifier((ECPublicKey) chain.get(0).getPublicKey()))) {
                throw new VerificationException("issuer-signature");
            }

            JWTClaimsSet claims = issuerJwt.getJWTClaimsSet();
            Instant now = clock.instant();
            Date exp = claims.getExpirationTime();
            if (exp == null || exp.toInstant().isBefore(now.minus(CLOCK_SKEW))) {
                throw new VerificationException("expired");
            }
            Date nbf = claims.getNotBeforeTime();
            if (nbf != null && nbf.toInstant().isAfter(now.plus(CLOCK_SKEW))) {
                throw new VerificationException("not-yet-valid");
            }

            Map<String, Object> disclosed = p.resolveDisclosures(claims);   // checks every digest
            if (!session.requiredClaimNames().containsAll(disclosed.keySet())) {
                throw new VerificationException("unrequested-claims");
            }

            Map<String, Object> cnf = claims.getJSONObjectClaim("cnf");
            JWK holderKey = JWK.parse((Map<String, Object>) cnf.get("jwk"));
            SignedJWT kb = p.keyBinding()
                    .orElseThrow(() -> new VerificationException("no-key-binding"));
            keyBinding.verify(kb, holderKey, session, p.presentationForHash(), now);

            statusClient.assertValid(claims.getJSONObjectClaim("status"), chain.get(0));

            return VerificationOutcome.accepted(disclosed,
                    chain.get(0).getSubjectX500Principal().getName());
        } catch (ParseException | JOSEException e) {
            throw new VerificationException("malformed");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And the key binding check itself, which is the part I would not leave to a library you have not read:

@Component
public class KeyBindingVerifier {

    private static final Duration MAX_AGE = Duration.ofMinutes(5);
    private static final Duration SKEW = Duration.ofSeconds(60);

    void verify(SignedJWT kb, JWK holderKey, VerifierSession session,
                String presentationForHash, Instant now)
            throws VerificationException, JOSEException, ParseException {

        if (!"kb+jwt".equals(kb.getHeader().getType() == null ? null : kb.getHeader().getType().getType())) {
            throw new VerificationException("kb-typ");
        }
        if (!kb.verify(new ECDSAVerifier(holderKey.toECKey()))) {
            throw new VerificationException("kb-signature");
        }

        JWTClaimsSet c = kb.getJWTClaimsSet();

        // aud must equal the client_id exactly as sent, scheme prefix included
        if (c.getAudience() == null || !c.getAudience().contains(session.clientId())) {
            throw new VerificationException("kb-aud");
        }
        if (!session.nonce().equals(c.getStringClaim("nonce"))) {
            throw new VerificationException("kb-nonce");
        }

        Instant iat = c.getIssueTime() == null ? null : c.getIssueTime().toInstant();
        if (iat == null || iat.isAfter(now.plus(SKEW)) || iat.isBefore(now.minus(MAX_AGE))) {
            throw new VerificationException("kb-iat");
        }

        // sd_hash covers everything up to and including the last '~' before the KB-JWT
        String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(
                MessageDigestUtil.sha256(presentationForHash.getBytes(StandardCharsets.US_ASCII)));
        if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
                String.valueOf(c.getClaim("sd_hash")).getBytes(StandardCharsets.US_ASCII))) {
            throw new VerificationException("kb-sd-hash");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

On clock skew: a phone clock that is two minutes off is not exotic, and the iat window is where it shows up first. I kept the skew at a minute and the maximum age at five, which matches the session TTL, rather than widening either to make a bad device clock pass.

I use Nimbus JOSE + JWT for signatures since Spring Security usually brings it along already. The SD-JWT parsing, digest checking and key binding I wrote myself, a few hundred lines in total. Libraries exist, including the EU reference implementations, and for production I would evaluate one, but writing it once showed me the sharp edges, and the spec's test vectors keep you honest.

Step four: the trust list question

Tutorials tend to skip this, and it is where the "EU" in EUDI becomes an engineering problem. A valid signature only proves somebody holding a private key signed the credential. What you need to know is whether that somebody is an authorised issuer of the credential type you are accepting.

That answer lives in the EU trusted lists. Each member state publishes a signed trusted list (ETSI TS 119 612 XML), and the European Commission publishes a list of lists (LOTL) pointing at them. The verifier must fetch the LOTL, check its signature against pinned signing certificates, follow the pointers to the national lists, check each of those, extract the service entries for the relevant provider types, and turn them into trust anchors for PKIX path validation.

I keep that work entirely out of the request path, in a scheduled loader:

@Component
public class TrustAnchorProvider {

    private final AtomicReference<TrustSnapshot> current =
            new AtomicReference<>(TrustSnapshot.empty());
    private final TrustListFetcher fetcher;

    @Scheduled(fixedDelayString = "PT1H", initialDelay = 0)
    void refresh() {
        try {
            TrustSnapshot next = fetcher.fetchAndValidate();   // LOTL -> national lists -> anchors
            if (next.anchorCount() < current.get().anchorCount() / 2
                    && current.get().anchorCount() > 0) {
                log.error("Trust snapshot shrank from {} to {} anchors, keeping previous",
                        current.get().anchorCount(), next.anchorCount());
                return;
            }
            current.set(next);
        } catch (Exception e) {
            log.warn("Trust list refresh failed, serving snapshot {}", current.get().version(), e);
        }
    }

    public void validate(List<X509Certificate> chain) throws VerificationException {
        TrustSnapshot snap = current.get();
        if (snap.isStale(Duration.ofHours(48))) {
            throw new VerificationException("trust-snapshot-stale");
        }
        snap.pkixValidate(chain);
    }
}
Enter fullscreen mode Exit fullscreen mode

The fetcher is where the real effort goes. Each list carries a NextUpdate timestamp and I honour it, so the hourly job usually re-checks and finds nothing new. Cert path validation is done with the JDK's PKIXParameters against the anchor set, revocation checking off for the chain itself because the trust list is the authority, and with the validation time taken from the injected Clock so tests can replay a past date. The signature on each national list is verified before a single entry is parsed. A list that fails its signature is dropped and the previous snapshot of that member state stays in place, rather than the whole refresh failing.

Three decisions are visible in the snippet. Request handling reads an in-memory snapshot and never touches the network, so a slow national server cannot hold up onboarding. A refresh that loses more than half the anchors is rejected and alerted on, since a parser bug or a truncated download should not quietly un-trust a country. And a snapshot older than 48 hours fails closed, because accepting credentials against a trust view you cannot vouch for is the wrong failure direction for a regulated firm.

I also store the snapshot version in each audit record. When a regulator or your fraud team asks why you accepted a credential on a particular date, you can answer with the exact trust data you held at that moment.

Step five: revocation without a privacy leak

Credentials get revoked: a lost phone, a corrected record, an expired residence permit. SD-JWT VC handles this through status references, typically a Token Status List. The credential holds a status claim with a list URI and an index. The list is a compressed bit array signed by the issuer, and you check the bit at your index.

The privacy argument is easy to miss. The list is public and covers a large population, so fetching it tells the issuer nothing about which person you are verifying. That is the point of preferring it to a per-credential online status call.

In practice you cache and respect the list's own time-to-live:

@Component
public class StatusListClient {

    private final RestClient http;
    private final OutboundHostPolicy hostPolicy;
    private final Cache<String, StatusList> cache = Caffeine.newBuilder()
            .maximumSize(500)
            .expireAfterWrite(Duration.ofMinutes(10))
            .build();

    public void assertValid(JSONObject statusClaim, X509Certificate issuerCert)
            throws VerificationException {
        if (statusClaim == null) {
            return;   // credential type may legitimately carry no status; policy decides
        }
        JSONObject ref = (JSONObject) statusClaim.get("status_list");
        String uri = (String) ref.get("uri");
        int idx = ((Number) ref.get("idx")).intValue();

        hostPolicy.assertAllowed(uri, issuerCert);   // allow-list before any network call

        StatusList list = cache.get(uri, this::download);   // list signature checked on parse
        if (list.statusAt(idx) != 0) {
            throw new VerificationException("credential-revoked-or-suspended");
        }
    }

    private StatusList download(String uri) {
        byte[] body = http.get().uri(uri).accept(MediaType.valueOf("application/statuslist+jwt"))
                .retrieve().body(byte[].class);
        return StatusList.parseAndVerify(body);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two cautions. The URI comes from inside the credential, so someone who builds a credential, even one that will fail signature verification later, can try to aim your service at an internal address. I verify the chain before fetching anything and limit outbound hosts to an allow-list derived from the issuer's trust list entry. This is the SSRF class of bug discussed in the Spring Boot 4.1 SSRF mitigation write-up. The order matters: never make a network call on the strength of unverified input.

The second is policy rather than code: what happens if the status list cannot be reached? For onboarding to a payment product I fail closed with a short retry, since KYC is a point-in-time control and a retry costs a customer half a minute. For a login-style re-authentication, living with a cached list for longer is a defensible choice.

What the audit record should hold

Compliance will want to know what you checked. I persist the session ID, the request profile name and version, the requested claim paths, the issuer certificate subject, the trust snapshot version, the status list URI with the fetch timestamp, and the outcome with its rejection reason. What I do not persist is the raw presentation, or any claim not required downstream.

The onboarding service downstream sees only the minimised outcome: the claims it asked for and a verification record ID. If your KYC provider needs a date of birth, it gets a date of birth, and the raw credential never leaves the verifier. The same instinct shaped how I handle evidence in DORA and NIS2 policy-as-code on Spring Boot: make evidence a by-product of the control, not a document someone assembles later.

Operating it for real

Some observations from building and load-testing this, informed by years of running trading and market-data systems where verification latency is directly visible:

  • Cost per verification is small. Two EC signature checks (issuer and key binding) plus a chain validation against in-memory anchors. On modest hardware the cryptography is not the bottleneck; the human at the consent screen is. Virtual threads suit the occasional blocking status fetch, and I covered the production details in Java virtual threads in Spring Boot.
  • Trace everything, log little. Carry a trace ID through request creation, response receipt and outcome delivery, and keep claim values out of spans and logs. Distributed tracing with OpenTelemetry is where I configured attribute filters for exactly this, and the same discipline applies to observability guardrails for agentic AI backends.
  • Test against more than one wallet. Implementations are still maturing, and I saw differences in how they encode claim paths, pick the key for direct_post.jwt and handle optional claims. Build integration tests around recorded real-wallet responses, not just your own generator, and note that a generator you wrote will agree with your own misreadings of the spec.
  • Keep format support narrow. ISO mdoc credentials (mobile driving licences above all) are also in the ecosystem. Start with SD-JWT VC for the PID, ship, and add mdoc as a second adapter behind the same verify interface when a concrete requirement appears.
  • Plan the unglamorous fallback. Many customers will not have a wallet in year one. Keep the wallet route beside your existing document-and-selfie flow, and have KYC orchestration treat "wallet verified" as one strong signal among several.

Where the standards still bite

This is not settled. DCQL details, status list drafts, and each member state's final attribute and certificate profile are all still moving. The sensible answer is isolation: my verifier exposes a narrow internal interface (start(profile), outcome(sessionId)), and everything protocol-specific sits behind it. When a draft changes I edit one module instead of the onboarding service.

There is also a real build-versus-buy question. A hosted verifier gets you moving sooner and carries someone else's maintenance load. Building gives you control over the trust snapshot, the audit trail and exact data flows, which for a regulated payments company can be the whole argument. Having co-founded a startup that was acquired (I wrote up what that taught me) and built payment-adjacent APIs for OpenBanking PSD2, my bias is to own the decision logic and the evidence, and to rent the commodity parts if you have to.

If you are weighing this for your team, my background across AirAsia-scale platforms (see building microservices at 130 million requests per day) and regulated fintech is on the experience section of this site, and you can reach me via contact if you want to compare notes.

A starting checklist for this quarter

Before the 2027 acceptance obligations bite, I would work in this order:

  1. Pick one onboarding journey and write down the minimum attribute set it needs, agreed with compliance. Turn it into a versioned DCQL profile.
  2. Stand up a verifier against a reference or test wallet environment, with the single-use session store and signed request objects.
  3. Implement strict SD-JWT parsing, including unrequested-claim rejection and key binding.
  4. Build the trust list loader as a background job with stale-snapshot protection.
  5. Add status list checking with an explicit fail-closed policy and an outbound host allow-list.
  6. Write the audit record and review it with your compliance and data protection people before any real user touches it.

Related Articles

This week, take the one KYC journey you would move to wallets first, write its minimum attribute list as a DCQL query in a Git-tracked file, and run that query against a test wallet to see which claims come back.

FAQ

What is an EUDI Wallet verifier?

An EUDI Wallet verifier is the relying-party service that requests a credential from an EU Digital Identity Wallet, receives the presentation over OpenID4VP, and validates the signatures, trust chain, key binding and revocation status. It returns a minimal outcome to your onboarding system and stores no identity documents.

Do I need to implement OpenID4VCI to accept wallet credentials?

No. OpenID4VCI is the issuance protocol used between the issuer and the wallet. As a verifier you only speak OpenID4VP, but you need to understand what issuance produced, because the claims, x5c chain and status reference you validate were all set at issuance.

How does selective disclosure work in SD-JWT VC?

The issuer-signed JWT carries only _sd digests of the hidden claims. The wallet sends a disclosure ([salt, claim_name, claim_value]) for each claim the user agrees to share, and the verifier recomputes the SHA-256 digest and checks that it appears in _sd. Any disclosure that matches no digest, or that you did not request, should be rejected.

How do I check whether a wallet credential has been revoked?

Read the status claim, fetch the signed Token Status List from the URI it points to, and check the bit at the credential's index. Cache the list according to its time-to-live, verify its signature before use, restrict outbound hosts to an allow-list, and decide up front whether you fail open or closed when the list is unreachable.

Top comments (0)