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:
- Builds an authorization request that names the credential type and the exact attributes you want.
- Gets that request in front of a wallet, through a QR code on desktop or a deep link on a phone.
- Accepts the presentation the wallet posts back.
- 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)| | | 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); } } 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 } } 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:
- 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.
- Validate the chain from the
x5cheader to a trust anchor taken from the EU trusted lists (below). - Verify the issuer signature with the leaf certificate's key.
- Check
vctagainst what you requested andexp/nbfagainst the clock, with a small skew allowance. - Verify the KB-JWT. Its signature must verify under the key in the credential's
cnfclaim, and it must carry yournonce, your prefixedclient_idasaud, a freshiat, and ansd_hashover the exact presentation string you received. - 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"); } } } 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)Source:https://dev.to/krishna_y/eudi-wallet-verifier-in-spring-boot-openid4vp-sd-jwt-vc-4gb3Related posts
Building a Great Open-Source Project Is Only Half the Journey
![AMR Software Architecture and Development — Ch.20: Cloud and Edge Integration [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzr2jyxw3pvhnrsrs9iz.png)
AMR Software Architecture and Development — Ch.20: Cloud and Edge Integration [KR]
![AMR Software Architecture and Development — Ch.21: Cybersecurity for AMR [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85j3mb5otb8bgxh118z7.png)
