Get your free and exclusive 80-page Banking Passkey Report
Blog-Post-Header-Image

How to Build a Digital Credential Verifier (Developer’s Guide)

Learn how to build a digital credential verifier from scratch using Next.js, OpenID4VP, and ISO mDoc. This step-by-step developer guide shows you how to create a verifier that can request, receive, and validate mobile driver's licenses and other digital c

Blog-Post-Author

Amine

Created: July 31, 2025

Updated: August 1, 2025


1. Introduction#

Proving identities online is a constant challenge, leading to a reliance on passwords and the sharing of sensitive documents over insecure channels. This has made identity verification for businesses a slow, expensive, and fraud-prone process. Digital Credentials offer a new approach, putting users back in control of their data. They are the digital equivalent of a physical wallet, containing everything from a driver's license to a university degree, but with the added benefits of being cryptographically secure, privacy-preserving, and instantly verifiable.

This guide provides developers with a practical, step-by-step tutorial for building a verifier for Digital Credentials. While the standards exist, there is little guidance on implementing them. This tutorial fills that gap, showing you how to build a verifier using the browser's native Digital Credential API, OpenID4VP for the presentation protocol, and ISO mDoc (e.g., mobile driver's license) as the credential format.

The end result will be a simple yet functional Next.js application that can request, receive, and verify a Digital Credential from a compatible mobile wallet.

Here is a quick look at the final application in action. The process involves four main steps:

Step 1: Initial Page The user lands on the initial page and clicks "Verify with Digital Identity" to start the process.

Step 2: Trust Prompt The browser prompts the user for trust. The user clicks "Continue" to proceed.

Step 3: QR Code Scan A QR code is displayed, which the user scans with their compatible wallet application.

Step 4: Decoded Credential After successful verification, the application displays the decoded credential data.

1.1 How It Works#

The magic behind digital credentials lies in a simple but powerful "trust triangle" model involving three key players:

  • Issuer: A trusted authority (e.g., a government agency, university, or bank) that cryptographically signs and issues a credential to a user.
  • Holder: The user, who receives the credential and stores it securely in a personal digital wallet on their device.
  • Verifier: An application or service that needs to check the user's credential.

When a user wants to access a service, they present the credential from their wallet. The verifier can then instantly check its authenticity without needing to contact the original issuer directly.

1.2 Why Verifiers Are Essential (and Why You're Here)#

For this decentralized identity ecosystem to flourish, the role of the verifier is absolutely critical. They are the gatekeepers of this new trust infrastructure, the ones who consume the credentials and make them useful in the real world. As the diagram below illustrates, a verifier completes the trust triangle by requesting, receiving, and validating a credential from the holder.

If you're a developer, building a service to perform this verification is a foundational skill for the next generation of secure and user-centric applications. This guide is designed to walk you through that exact process. We'll cover everything you need to know to build your own verifiable credential verifier, from the core concepts and standards to the step-by-step implementation details of validating signatures and checking credential status.

Want to skip ahead? You can find the complete, finished project for this tutorial on GitHub. Feel free to clone it and try it out yourself: https://github.com/corbado/digital-credentials-example

Let's get started.

2. Prerequisites to Build a Verifier#

Before you start, make sure you have:

  1. Basic Understanding of Digital Credentials and mdoc
    • This tutorial focuses on the ISO mDoc format (e.g., for mobile driver's licenses) and does not cover other formats like W3C Verifiable Credentials (VCs). Familiarity with the basic concepts of mdoc will be helpful.
  2. Docker and Docker Compose
    • Our project uses a MySQL database in a Docker container to manage OIDC session state. Make sure you have both installed and running.
  3. Chosen Protocol: OpenID4VP
    • We will use the OpenID4VP (OpenID for Verifiable Presentations) protocol for the credential exchange flow.
  4. Tech Stack Ready
    • Use TypeScript (Node.js) for backend logic.
    • Use Next.js for both backend (API routes) and frontend (UI).
    • Key libraries: CBOR decoding libraries for mdoc parsing and a MySQL client.
  5. Test Credentials and Wallet
  6. Basic Cryptography Knowledge
    • Understand digital signatures and public/private key concepts as they relate to mdoc and OIDC flows.

We'll now go through each of these prerequisites in detail, starting with the standards and protocols that underpin this mdoc-based verifier.

2.1 Protocol Choices#

Our verifier is built for the following:

Standard / ProtocolDescription
W3C VCThe W3C Verifiable Credentials Data Model. It defines the standard structure for digital credentials, including claims, metadata, and proofs.
SD-JWTSelective Disclosure for JWTs. A format for VCs based on JSON Web Tokens that allows holders to selectively disclose only specific claims from a credential, enhancing privacy.
ISO mDocISO/IEC 18013-5. The international standard for mobile Driver's Licenses (mDLs) and other mobile IDs, defining data structures and communication protocols for offline and online use.
OpenID4VPOpenID for Verifiable Presentations. An interoperable presentation protocol built on OAuth 2.0. It defines how a verifier requests credentials and a holder's wallet presents them.

For this tutorial, we specifically use:

  • OpenID4VP as the protocol for requesting and receiving credentials.
  • ISO mDoc as the credential format (e.g., for mobile driver's licenses).

Note on Scope: While we briefly introduce W3C VC and SD-JWT to provide broader context, this tutorial exclusively implements ISO mDoc credentials via OpenID4VP. W3C-based VCs are out of scope for this example.

2.1.1 ISO mDoc (Mobile Document)#

The ISO/IEC 18013-5 mDoc standard defines the structure and encoding for mobile documents such as mobile driver's licenses (mDLs). mDoc credentials are CBOR-encoded, cryptographically signed, and can be presented digitally for verification. Our verifier will focus on decoding and validating these mdoc credentials.

2.1.2 OpenID4VP (OpenID for Verifiable Presentations)#

OpenID4VP is an interoperable protocol for requesting and presenting digital credentials, built on top of OAuth 2.0 and OpenID Connect. In this implementation, OpenID4VP is used to:

  • Initiate the credential presentation flow (via QR code or browser API)
  • Receive the mdoc credential from the user's wallet
  • Ensure secure, stateful, and privacy-preserving credential exchange

2.2 Tech Stack Choices#

Now that we have a clear understanding of the standards and protocols, we need to choose the right tech stack to build our verifier. Our choices are designed for robustness, developer experience, and compatibility with the modern web ecosystem.

2.2.1 Language: TypeScript#

We'll use TypeScript for both our frontend and backend code. As a superset of JavaScript, it adds static typing, which helps catch errors early, improves code quality, and makes complex applications easier to manage. In a security-sensitive context like credential verification, type safety is a massive advantage.

2.2.2 Framework: Next.js#

Next.js is our framework of choice because it provides a seamless, integrated experience for building full-stack applications.

  • For the Frontend: We'll use Next.js with React to build the user interface where the verification process is initiated (e.g., displaying a QR code).
  • For the Backend: We'll leverage Next.js API Routes to create the server-side endpoints. These endpoints are responsible for creating valid OpenID4VP requests and for acting as the redirect_uri to securely receive and verify the final response from the CMWallet.

2.2.3 Key Libraries#

Our implementation relies on a specific set of libraries for the frontend and backend:

  • next: The Next.js framework, used for both backend API routes and frontend UI.
  • react and react-dom: Power the frontend user interface.
  • cbor-web: Used to decode CBOR-encoded mdoc credentials into usable JavaScript objects.
  • mysql2: Provides MySQL database connectivity for storing challenges and verification sessions.
  • uuid: A library for generating unique challenge strings (nonces).
  • @types/uuid: TypeScript types for UUID generation.

Note on openid-client: More advanced, production-grade verifiers might use the openid-client library to handle the OpenID4VP protocol directly on the backend, enabling features like a dynamic redirect_uri. In a server-driven OpenID4VP flow with a redirect_uri, openid-client would be used to parse and validate vp_token responses directly. For this tutorial, we are using a simpler, browser-mediated flow that does not require it, making the process easier to understand.

This tech stack ensures a robust, type-safe, and scalable verifier implementation focused on the browser's Digital Credential API and ISO mDoc credential format.

2.3 Get a Test Wallet and Credentials#

To test your verifier, you need a mobile wallet that can interact with the browser's Digital Credential API.

We'll use the CMWallet, a robust OpenID4VP-compliant testing wallet for Android.

How to Install CMWallet (Android):

  1. Download the APK file using the link above directly on your Android device.
  2. Open your device's Settings > Security.
  3. Enable "Install unknown apps" for the browser you used to download the file.
  4. Locate the downloaded APK in your "Downloads" folder and tap it to begin installation.
  5. Follow the on-screen prompts to complete the installation.
  6. Open CMWallet, and you will find it pre-loaded with test credentials, ready for the verification flow.

Note: Only install APK files from sources you trust. The link provided is from the official project repository.

2.4 Cryptography Knowledge#

Before we dive into the implementation, it's essential to understand the cryptographic concepts that underpin verifiable credentials. This is what makes them "verifiable" and trustworthy.

2.4.1 Digital Signatures: The Foundation of Trust#

At its heart, a Verifiable Credential is a set of claims (like name, date of birth, etc.) that has been digitally signed by an issuer. A digital signature provides two critical guarantees:

  • Authenticity: It proves that the credential was indeed created by the issuer and not by an imposter.
  • Integrity: It proves that the credential has not been altered or tampered with since it was signed.

2.4.2 Public/Private Key Cryptography#

Digital signatures are created using public/private key cryptography (also called asymmetric cryptography). Here’s how it works in our context:

  1. The Issuer has a key pair: a private key, which is kept secret, and a public key, which is made available to everyone (usually via their DID Document).
  2. Signing: When an issuer creates a credential, they use their private key to generate a unique digital signature for that specific credential data.
  3. Verification: When our verifier receives the credential, it uses the issuer's public key to check the signature. If the check passes, the verifier knows the credential is authentic and has not been tampered with. Any change to the credential data would invalidate the signature.

Note on DIDs: In this tutorial, we don’t resolve issuer keys via DIDs. In production, issuers would typically expose public keys via DIDs or other authoritative endpoints, which the verifier would use for cryptographic validation.

2.4.3 Verifiable Credentials as JWTs#

Verifiable Credentials are often formatted as JSON Web Tokens (JWTs). A JWT is a compact, URL-safe way to represent claims to be transferred between two parties. A signed JWT (also known as a JWS) has three parts separated by dots (.):

  • Header: Contains metadata about the token, like the signing algorithm used (alg).
  • Payload: Contains the actual claims of the Verifiable Credential (vc claim), including the issuer, credentialSubject, etc.
  • Signature: The digital signature generated by the issuer, which covers the header and the payload.
// Example of a JWT structure [Header].[Payload].[Signature]

Note: JWT-based Verifiable Credentials are out of scope for this blog post. This implementation focuses on ISO mDoc credentials and OpenID4VP, not W3C Verifiable Credentials or JWT-based credentials.

2.4.4 The Verifiable Presentation: Proving Possession#

It’s not enough for a verifier to know that a credential is valid; it also needs to know that the person presenting the credential is the legitimate holder. This prevents someone from using a stolen credential.

This is solved using a Verifiable Presentation (VP). A VP is a wrapper around one or more VCs that is signed by the holder themselves.

The flow is as follows:

  1. The verifier asks the user to present a credential.
  2. The user's wallet creates a Verifiable Presentation, bundles the required credential(s) inside it, and signs the entire presentation using the holder's private key.
  3. The wallet sends this signed VP to the verifier.

Our verifier must then perform two separate signature checks:

  1. Verify the Credential(s): Check the signature on each VC inside the presentation using the issuer's public key. (Proves the credential is real).
  2. Verify the Presentation: Check the signature on the VP itself using the holder's public key. (Proves the person presenting it is the owner).

This two-level check ensures both the authenticity of the credential and the identity of the person presenting it, creating a robust and secure trust model.

Note: The concept of Verifiable Presentations as defined in the W3C VC ecosystem is out of scope for this blog post. The term Verifiable Presentation here refers to the OpenID4VP vp_token response, which behaves similarly to a W3C VP but is based on ISO mDoc semantics rather than W3C's JSON-LD signature model. This guide focuses on ISO mDoc credentials and OpenID4VP, not on W3C Verifiable Presentations or their signature validation.

3. Architectural Overview#

Our verifier architecture uses the browser's built-in Digital Credential API as a secure intermediary to connect our web application with the user's mobile CMWallet. This approach simplifies the flow by letting the browser handle the native QR code display and wallet communication.

  • Frontend (Next.js & React): A lightweight user-facing website. Its job is to fetch a request object from our backend, pass it to the browser's navigator.credentials.get() API, receive the result, and forward it to our backend for verification.
  • Backend (Next.js API Routes): The powerhouse of the verifier. It generates a valid request object for the browser API and exposes an endpoint to receive the credential presentation from the frontend for final validation.
  • Browser (Credential API): The facilitator. It receives the request object from our frontend, understands the openid4vp protocol, and natively generates a QR code. It then waits for the wallet to return a response.
  • CMWallet (Mobile App): The user's wallet. It scans the QR code, processes the request, gets user consent, and sends the signed response back to the browser.

Here is a sequence diagram illustrating the complete and accurate flow:

The Flow Explained:

  1. Initiation: The user clicks the "Verify" button on our Frontend.
  2. Request Object: The frontend calls our Backend (/api/verify/start), which generates a request object containing the query and a nonce, then returns it.
  3. Browser API Call: The frontend calls navigator.credentials.get() with the request object.
  4. Native QR Code: The Browser sees the openid4vp protocol request and natively displays a QR code. The .get() promise is now pending.

Note: This QR code flow occurs on desktop browsers. On mobile browsers (Android Chrome with experimental flag enabled), the browser can directly communicate with compatible wallets on the same device, eliminating the need for QR code scanning. To enable this feature on Android Chrome, navigate to chrome://flags#web-identity-digital-credentials and set the flag to "Enabled".

  1. Scan & Present: The user scans the QR code with CMWallet. The wallet gets user approval and sends the Verifiable Presentation back to the browser.
  2. Promise Resolution: The browser receives the response, and the original .get() promise on the frontend finally resolves, delivering the presentation payload.
  3. Backend Verification: The frontend POSTs the presentation payload to our backend's /api/verify/finish endpoint. The backend validates the nonce and credential.
  4. Result: The backend returns a final success or failure message to the frontend, which updates the UI.

4. Building the Verifier#

Now that we have a solid understanding of the standards, protocols, and architectural flow, we can start building our verifier.

Follow Along or Use the Final Code

We will now go through the setup and code implementation step by step. If you prefer to jump straight to the finished product, you can clone the complete project from our GitHub repository and run it locally.

git clone https://github.com/corbado/digital-credentials-example.git

4.1 Setting Up the Project#

First, we'll initialize a new Next.js project, install the necessary dependencies, and start our database.

4.1.1 Initializing the Next.js App#

Open your terminal, navigate to the directory where you want to create your project, and run the following command. We're using the App Router, TypeScript, and Tailwind CSS for this project.

npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm

This command scaffolds a new Next.js application in your current directory.

4.1.2 Installing Dependencies#

Next, we need to install the libraries that will handle CBOR decoding, database connections, and UUID generation.

npm install cbor-web mysql2 uuid @types/uuid

This command installs:

  • cbor-web: For decoding the mdoc credential payload.
  • mysql2: The MySQL client for our database.
  • uuid: To generate unique challenge strings.
  • @types/uuid: TypeScript types for the uuid library.

4.1.3 Starting the Database#

Our backend requires a MySQL database to store OIDC session data, ensuring that each verification flow is secure and stateful. We've included a docker-compose.yml file to make this easy.

If you have cloned the repository, you can simply run docker-compose up -d. If you are building from scratch, create a file named docker-compose.yml with the following content:

services: mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: digital_credentials MYSQL_USER: app_user MYSQL_PASSWORD: app_password ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 volumes: mysql_data:

This Docker Compose setup also requires a SQL initialization script. Create a directory named sql and inside it, a file named init.sql with the following content to set up the necessary tables:

-- Create database if not exists CREATE DATABASE IF NOT EXISTS digital_credentials; USE digital_credentials; -- Table for storing challenges CREATE TABLE IF NOT EXISTS challenges ( id VARCHAR(36) PRIMARY KEY, challenge VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_challenge (challenge), INDEX idx_expires_at (expires_at) ); -- Table for storing verification sessions CREATE TABLE IF NOT EXISTS verification_sessions ( id VARCHAR(36) PRIMARY KEY, challenge_id VARCHAR(36), status ENUM('pending', 'verified', 'failed', 'expired') DEFAULT 'pending', presentation_data JSON, verified_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, INDEX idx_challenge_id (challenge_id), INDEX idx_status (status) ); -- Table for storing verified credentials data (optional) CREATE TABLE IF NOT EXISTS verified_credentials ( id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36), credential_type VARCHAR(255), issuer VARCHAR(255), subject VARCHAR(255), claims JSON, verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES verification_sessions(id) ON DELETE CASCADE, INDEX idx_session_id (session_id), INDEX idx_credential_type (credential_type) );

Once both files are in place, open your terminal in the project root and run:

docker-compose up -d

This command will start a MySQL container in the background.

4.2 Architectural Overview of the Next.js App#

Our Next.js application is structured to separate concerns between the frontend and backend, even though they are part of the same project.

  • Frontend (src/app/page.tsx): A single React page that initiates the verification flow and displays the result. It interacts with the browser's Digital Credential API.
  • Backend API Routes (src/app/api/verify/...):
    • start/route.ts: Generates the OpenID4VP request and a security nonce.
    • finish/route.ts: Receives the presentation from the wallet (via the browser), validates the nonce, and decodes the credential.
  • Library (src/lib/):
    • database.ts: Manages all database interactions (creating challenges, verifying sessions).
    • crypto.ts: Handles the decoding of the CBOR-based mDoc credential.

Here is a diagram illustrating the internal architecture:

4.3 Building the Frontend#

Our frontend is intentionally lightweight. Its primary responsibility is to act as the user-facing trigger for the verification flow and to communicate with both our backend and the browser's native credential handling capabilities. It doesn't contain any complex protocol logic itself; that's all delegated.

Specifically, the frontend will handle the following:

  • User Interaction: Provides a simple interface, like a "Verify" button, for the user to start the process.
  • State Management: Manages the UI state, showing loading indicators while verification is in progress and displaying the final success or error message.
  • Backend Communication (Request): Calls /api/verify/start and receives a structured JSON payload (protocol, request, state) describing exactly what the wallet should present.
  • Browser API Invocation: Hands that JSON object to navigator.credentials.get(), which renders a native QR code and waits for the wallet response.
  • Backend Communication (Response): Once the browser API returns the Verifiable Presentation, it sends this data to our /api/verify/finish endpoint in a POST request for the final server-side validation.
  • Displaying Results: Updates the UI to inform the user whether the verification was successful or failed, based on the response from the backend.

The core logic is in the startVerification function:

// src/app/page.tsx const startVerification = async () => { setLoading(true); setVerificationResult(null); try { // 1. Check if the browser supports the API if (!navigator.credentials?.get) { throw new Error("Browser does not support the Credential API."); } // 2. Ask our backend for a request object const res = await fetch("/api/verify/start"); const { protocol, request } = await res.json(); // 3. Hand that object to the browser – this triggers the native QR code const credential = await (navigator.credentials as any).get({ mediation: "required", digital: { requests: [ { protocol, // "openid4vp" data: request, // contains dcql_query, nonce, etc. }, ], }, }); // 4. Forward the wallet response (from the browser) to our finish endpoint for server-side checks const verifyRes = await fetch("/api/verify/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (verifyRes.ok && result.verified) { setVerificationResult(`Success: ${result.message}`); } else { throw new Error(result.message || "Verification failed."); } } catch (err) { setVerificationResult(`Error: ${(err as Error).message}`); } finally { setLoading(false); } };

This function shows the four key steps of the frontend logic: checking for API support, fetching the request from the backend, calling the browser API, and sending the result back for verification. The rest of the file is standard React boilerplate for state and UI rendering, which you can view in the GitHub repository.

Why digital and mediation: 'required'?#

You might notice our call to navigator.credentials.get() looks different from simpler examples. This is because we are adhering strictly to the official W3C Digital Credentials API specification.

  • digital Member: The specification requires that all digital credential requests be nested inside a digital object. This provides a clear, standardized namespace for this API, distinguishing it from other credential types (like password or federated) and allowing for future extensions without conflicts.

  • mediation: 'required': This option is a crucial security and user-experience feature. It enforces that the user must actively interact with a prompt (e.g., a biometric scan, PIN entry, or a consent screen) to approve the credential request. Without it, a website could potentially attempt to silently access credentials in the background, which poses a significant privacy risk. By requiring mediation, we ensure that the user is always in control and gives explicit consent for every transaction.

4.4 Building the Backend Endpoints#

With the React UI in place we now need two API routes that do the heavy-lifting on the server:

  1. /api/verify/start – builds an OpenID4VP request, persists a one-time challenge in MySQL and hands everything back to the browser.
  2. /api/verify/finish – receives the wallet response, validates the challenge, verifies & decodes the credential, and finally returns a concise JSON result to the UI.

4.4.1 /api/verify/start: Generate the OpenID4VP Request#

// src/app/api/verify/start/route.ts import { NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { createChallenge, cleanupExpiredChallenges } from "@/lib/database"; export async function GET() { // 1️⃣ Create a short-lived, random nonce (challenge) const challenge = uuidv4(); const challengeId = uuidv4(); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); await createChallenge(challengeId, challenge, expiresAt); cleanupExpiredChallenges().catch(console.error); // 2️⃣ Build a DCQL query that describes *what* we want const dcqlQuery = { credentials: [ { id: "cred1", format: "mso_mdoc", meta: { doctype_value: "eu.europa.ec.eudi.pid.1" }, claims: [ { path: ["eu.europa.ec.eudi.pid.1", "family_name"] }, { path: ["eu.europa.ec.eudi.pid.1", "given_name"] }, { path: ["eu.europa.ec.eudi.pid.1", "birth_date"] }, ], }, ], }; // 3️⃣ Return an object the browser can pass to navigator.credentials.get() return NextResponse.json({ protocol: "openid4vp", // tells the browser which wallet protocol to use request: { dcql_query: dcqlQuery, // WHAT to present nonce: challenge, // anti-replay response_type: "vp_token", response_mode: "dc_api", // the wallet will POST directly to /finish }, state: { credential_type: "mso_mdoc", // kept for later checks nonce: challenge, challenge_id: challengeId, }, }); }

Key parameters

noncecryptographic challenge that binds request & response (prevents replay).
dcql_query – An object describing the exact claims we need. For this guide, we use a dcql_query structure inspired by recent drafts of the Digital Credential Query Language, even though this is not yet a finalized standard.
state – arbitrary JSON echoed back by the wallet so we can look up the DB record.

4.4.2 Database Helpers#

The file src/lib/database.ts wraps the basic MySQL operations for challenges & verification sessions (insert, read, mark-used). Keeping this logic in a single module makes it easy to swap the datastore later.


4.5 /api/verify/finish: Validate & Decode the Presentation#

// src/app/api/verify/finish/route.ts import { NextResponse, NextRequest } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getChallenge, markChallengeAsUsed, createVerificationSession, updateVerificationSession, } from "@/lib/database"; import { decodeDigitalCredential, decodeAllNamespaces } from "@/lib/crypto"; export async function POST(request: NextRequest) { const body = await request.json(); // 1️⃣ Extract the verifiable presentation pieces const vpTokenMap = body.vp_token ?? body.data?.vp_token; const state = body.state; const mdocToken = vpTokenMap?.cred1; // we asked for this ID in dcqlQuery if (!vpTokenMap || !state || !mdocToken) { return NextResponse.json( { verified: false, message: "Malformed response" }, { status: 400 }, ); } // 2️⃣ One-time-use challenge validation const stored = await getChallenge(state.nonce); if (!stored) { return NextResponse.json( { verified: false, message: "Invalid or expired challenge" }, { status: 400 }, ); } const sessionId = uuidv4(); await createVerificationSession(sessionId, stored.id); // 3️⃣ (Pseudo) cryptographic checks – replace with real mDL validation in prod // In a real application, you would use a dedicated library to perform full // cryptographic validation of the mdoc signature against the issuer's public key. const isValid = mdocToken.length > 0; if (!isValid) { await updateVerificationSession(sessionId, "failed", { reason: "mdoc validation failed", }); return NextResponse.json( { verified: false, message: "Credential validation failed" }, { status: 400 }, ); } // 4️⃣ Decode the mobile-DL (mdoc) payload into human readable JSON const decoded = await decodeDigitalCredential(mdocToken); const readable = decodeAllNamespaces(decoded)["eu.europa.ec.eudi.pid.1"]; await markChallengeAsUsed(state.nonce); await updateVerificationSession(sessionId, "verified", { readable }); return NextResponse.json({ verified: true, message: "mdoc credential verified successfully!", credentialData: readable, sessionId, }); }

Important fields in the wallet response

vp_token – map that holds each credential the wallet returns. For our demo we pull vp_token.cred1.
state – echo of the blob we provided in /start; contains the nonce so we can look up the DB record.
mdocToken – a Base64URL-encoded CBOR structure that represents the ISO mDoc.

4.6 Decoding the mdoc Credential#

When the verifier receives an mdoc credential from the browser, it is a Base64URL string containing CBOR-encoded binary data. To extract the actual claims, the finish endpoint performs a multi-step decoding process using helper functions from src/lib/crypto.ts.

4.6.1 Step 1: Base64URL and CBOR Decoding#

The decodeDigitalCredential function handles the conversion from the encoded string to a usable object:

// src/lib/crypto.ts export async function decodeDigitalCredential(encodedCredential: string) { // 1. Convert Base64URL to standard Base64 const base64UrlToBase64 = (input: string) => { let base64 = input.replace(/-/g, "+").replace(/_/g, "/"); const pad = base64.length % 4; if (pad) base64 += "=".repeat(4 - pad); return base64; }; const base64 = base64UrlToBase64(encodedCredential); // 2. Decode Base64 to binary const binaryString = atob(base64); const byteArray = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); // 3. Decode CBOR const decoded = await cbor.decodeFirst(byteArray); return decoded; }
  • Base64URL to Base64: Converts the credential from Base64URL to standard Base64 encoding.
  • Base64 to Binary: Decodes the Base64 string into a binary byte array.
  • CBOR Decoding: Uses the cbor-web library to decode the binary data into a structured JavaScript object.

4.6.2 Step 2: Extracting Namespaced Claims#

The decodeAllNamespaces function further processes the decoded CBOR object to extract the actual claims from the relevant namespaces:

// src/lib/crypto.ts export function decodeAllNamespaces(jsonObj) { const decoded = {}; try { jsonObj.documents.forEach((doc, idx) => { // 1) issuerSigned.nameSpaces: const issuerNS = doc.issuerSigned?.nameSpaces || {}; Object.entries(issuerNS).forEach(([nsName, entries]) => { if (!decoded[nsName]) decoded[nsName] = {}; (entries as any[]).forEach((entry) => { const bytes = Uint8Array.from(entry.value); const decodedEntry = cbor.decodeFirstSync(bytes); Object.assign(decoded[nsName], decodedEntry); }); }); // 2) deviceSigned.nameSpaces (if present): const deviceNS = doc.deviceSigned?.nameSpaces; if (deviceNS?.value?.data) { const bytes = Uint8Array.from(deviceNS.value); decoded[`deviceSigned_ns_${idx}`] = cbor.decodeFirstSync(bytes); } }); } catch (e) { console.error(e); } return decoded; }
  • Iterates over all documents in the decoded credential.
  • Decodes each namespace (e.g., eu.europa.ec.eudi.pid.1) to extract the actual claim values (such as name, date of birth, etc.).
  • Handles both issuer-signed and device-signed namespaces if present.

Example Output#

After running through these steps, the finish endpoint obtains a human-readable object containing the claims from the mdoc, for example:

{ "family_name": "Doe", "given_name": "John", "birth_date": "1990-01-01" }

This process ensures that the verifier can securely and reliably extract the necessary information from the mdoc credential for display and further processing.

4.7 Surfacing the Result in the UI#

The finish endpoint returns a minimal JSON object to the frontend:

{ "verified": true, "message": "mdoc credential verified successfully!", "credentialData": { "family_name": "Doe", "given_name": "John", "birth_date": "1990-01-01" } }

The frontend receives this response in startVerification() and simply persists it in React state so we can render a nice confirmation card or display individual claims – e.g. “Welcome, John Doe (born 1990-01-01)!”.

5. Running the Verifier and Next Steps#

You now have a complete, working verifier that uses the browser's native credential handling capabilities. Here’s how to run it locally and what you can do to take it from a proof-of-concept to a production-ready application.

5.1 How to Run the Example#

  1. Clone the Repository:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Install Dependencies:

    npm install
  3. Start the Database: Make sure Docker is running on your machine, then start the MySQL container:

    docker-compose up -d
  4. Run the Application:

    npm run dev

    Open your browser to http://localhost:3000, and you should see the verifier's UI. You can now use your CMWallet to scan the QR code and complete the verification flow.

5.2 Next Steps: From Demo to Production#

This tutorial provides the foundational building blocks for a verifier. To make it production-ready, you would need to implement several additional features:

  • Full Cryptographic Validation: The current implementation uses a placeholder check (mdocToken.length > 0). In a real-world scenario, you must perform full cryptographic validation of the mdoc signature against the issuer's public key (e.g., by resolving their DID or fetching their public key certificate). For DID resolution standards, refer to the W3C DID Resolution specification.

  • Issuer Revocation Checking: Credentials can be revoked by the issuer before their expiry date. A production verifier must check the credential's status by querying a revocation list or status endpoint provided by the issuer. The W3C Verifiable Credentials Status List provides the standard for credential revocation lists.

  • Robust Error Handling & Security: Add comprehensive error handling, input validation, rate-limiting on API endpoints, and ensure all communication is over HTTPS (TLS) to protect data in transit. The OWASP API Security Guidelines provide comprehensive API security best practices.

  • Support for Multiple Credential Types: Extend the logic to handle different doctype values and credential formats if you expect to receive more than just the European Digital Identity (EUDI) PID credential. The W3C Verifiable Credentials Data Model provides comprehensive VC format specifications.

5.3 What's Out of Scope for This Tutorial#

This example is intentionally focused on the core browser-mediated flow to make it easy to understand. The following topics are considered out of scope:

  • Production-Ready Security: The verifier is for educational purposes and lacks the hardening required for a live environment.
  • W3C Verifiable Credentials: This tutorial focuses exclusively on the ISO mDoc format for mobile driver's licenses. It does not cover other popular formats like JWT-VCs or VCs with Linked Data Proofs (LD-Proofs).
  • Advanced OpenID4VP Flows: We do not implement more complex OpenID4VP features, such as direct wallet-to-backend communication using a redirect_uri or dynamic client registration.

By building on this foundation and incorporating these next steps, you can develop a robust and secure verifier capable of trusting and validating digital credentials in your own applications.

Conclusion#

That’s it! With fewer than 250 lines of TypeScript, we now have an end-to-end verifier that:

  1. Publishes a request for the browser's credential API.
  2. Lets any compliant wallet supply a Verifiable Presentation.
  3. Validates the presentation on the server.
  4. Updates the UI in real-time.

In production, you would replace the placeholder validation with full ISO 18013-5 checks, add issuer revocation look-ups, rate-limiting, audit logging, and of course, end-to-end TLS—but the core building blocks stay exactly the same.

Resources#

Here are some of the key resources, specifications, and tools used or referenced in this tutorial:

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start for free

Share this article


LinkedInTwitterFacebook

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.

Related Articles