---
url: 'https://www.corbado.com/blog/how-to-build-verifiable-credential-verifier'
title: 'How to build a Digital Credential Verifier (Developer’s Guide)'
description: 'Learn how to build a digital credential verifier using Next.js, OpenID4VP & mDoc. The verifier can request, receive & validate digital verifiable credentials.'
lang: 'en'
author: 'Amine'
date: '2025-07-31T09:34:31.853Z'
lastModified: '2026-03-27T07:01:41.381Z'
keywords: 'digital credentials verifier, tutorial verifier, build verifier'
category: 'Digital Credentials'
---

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

## Key Facts

- A functional digital credential verifier builds in fewer than 250 lines of TypeScript,
  using Next.js, OpenID4VP and the **ISO mDoc** credential format for mobile identity
  documents.
- The **browser's Digital Credential API** (`navigator.credentials.get()`) acts as a
  secure intermediary, natively rendering a QR code and managing wallet communication
  without custom protocol code.
- The **trust triangle** defines three roles: an issuer who cryptographically signs
  credentials, a holder who stores them, and a verifier who validates without contacting
  the issuer.
- **CBOR decoding** requires converting the Base64URL-encoded mdoc payload to binary, then
  parsing namespaced claims with `cbor-web` to extract human-readable fields like name and
  birth date.

## 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](https://www.corbado.com/blog/digital-wallet-assurance), 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](https://www.corbado.com/blog/digital-credentials-api). 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](https://www.corbado.com/glossary/open-id-4-vp) for the presentation protocol, and ISO
[mDoc](https://www.corbado.com/glossary/mdoc) (e.g., mobile driver's license) as the credential format.

The end result will be a simple yet functional [Next.js](https://www.corbado.com/blog/nextjs-passkeys)
application that can request, receive, and verify a Digital Credential from a compatible
mobile [wallet](https://www.corbado.com/blog/digital-wallet-assurance).

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](https://www.corbado.com/blog/digital-identity-guide)" to start the process.
![Initial page for verification request](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Screenshot_2025_07_25_at_11_00_33_5217b35c96.png)

**Step 2: Trust Prompt** The browser prompts the user for trust. The user clicks
"Continue" to proceed.
![Browser trust prompt](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Screenshot_2025_07_25_at_11_00_39_ba390a8097.png)

**Step 3: QR Code Scan** A [QR code](https://www.corbado.com/blog/qr-code-login-authentication) is displayed,
which the user scans with their compatible [wallet](https://www.corbado.com/blog/digital-wallet-assurance)
application.
![QR code for scanning](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Screenshot_2025_07_25_at_11_00_45_3b30669a10.png)

**Step 4: Decoded Credential** After successful verification, the application displays the
decoded credential data.
![Decoded credential result](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Screenshot_2025_07_25_at_11_01_36_684f7489cd.png)

### 1.1 How It Works

The magic behind [digital credentials](https://www.corbado.com/blog/digital-credentials-api) lies in a simple but
powerful "**trust triangle**" model involving three key players:

- **Issuer:** A trusted authority (e.g., a [government](https://www.corbado.com/passkeys-for-public-sector)
  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](https://www.corbado.com/blog/digital-wallet-assurance) on their device.
- **Verifier:** An application or service that needs to check the user's credential.

![W3C Verifiable Credentials Ecosystem](https://www.w3.org/TR/vc-data-model/diagrams/ecosystem.svg)

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](https://www.corbado.com/glossary/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](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](https://www.corbado.com/glossary/microcredentials) (VCs). Familiarity with the
      basic concepts of [mdoc](https://www.corbado.com/glossary/mdoc) will be helpful.
2. **Docker and Docker Compose**
    - Our project uses a [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 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](https://www.corbado.com/glossary/cbor) decoding libraries for [mdoc](https://www.corbado.com/glossary/mdoc)
      parsing and a [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) client.
5. **Test Credentials and Wallet**
    - We will use the
      **[CMWallet](https://github.com/digitalcredentialsdev/CMWallet/actions/runs/16407676816/artifacts/3574255220)**
      for [Android](https://www.corbado.com/blog/how-to-enable-passkeys-android), which understands
      [OpenID4VP](https://www.corbado.com/glossary/open-id-4-vp) requests and can present mdoc credentials.
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 / Protocol                                                                       | Description                                                                                                                                                                            |
| :---------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **[W3C VC](https://www.w3.org/TR/vc-data-model/)**                                        | The W3C Verifiable Credentials Data Model. It defines the standard structure for digital credentials, including claims, metadata, and proofs.                                          |
| **[SD-JWT](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/)** | Selective 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 mDoc](https://www.iso.org/standard/69084.html)**                                   | ISO/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. |
| **OpenID4VP**                                                                             | OpenID 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](https://www.corbado.com/glossary/open-id-4-vp). 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](https://www.corbado.com/glossary/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](https://www.corbado.com/blog/digital-credentials-api), built on top of
[OAuth 2.0](https://www.corbado.com/glossary/oauth2) and OpenID Connect. In this implementation, OpenID4VP is
used to:

- Initiate the credential presentation flow (via
  [QR code](https://www.corbado.com/blog/qr-code-login-authentication) 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](https://www.corbado.com/blog/nextjs-passkeys) with
  [React](https://www.corbado.com/blog/react-passkeys) to build the user interface where the verification process
  is initiated (e.g., displaying a [QR code](https://www.corbado.com/blog/qr-code-login-authentication)).
- **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](https://www.corbado.com/blog/nextjs-passkeys) 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](https://www.corbado.com/glossary/cbor)-encoded mdoc credentials into usable
  JavaScript objects.
- **mysql2**: Provides [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 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](https://github.com/digitalcredentialsdev/CMWallet/actions/runs/16407676816/artifacts/3574255220)**,
a robust OpenID4VP-compliant testing wallet for
[Android](https://www.corbado.com/blog/how-to-enable-passkeys-android).

**How to Install CMWallet (Android):**

1. **Download the APK file** using the link above directly on your
   [Android](https://www.corbado.com/blog/how-to-enable-passkeys-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](https://www.corbado.com/glossary/microcredentials). This is what
makes them "verifiable" and trustworthy.

#### 2.4.1 Digital Signatures: The Foundation of Trust

At its heart, a [Verifiable Credential](https://www.corbado.com/glossary/verifiable-credential) is a set of
claims (like name, date of birth, etc.) that has been digitally signed by an
[issuer](https://www.corbado.com/glossary/issuer). A digital signature provides two critical guarantees:

- **Authenticity:** It proves that the credential was indeed created by the
  [issuer](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/issuer) **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](https://www.corbado.com/glossary/issuer) 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](https://www.corbado.com/glossary/microcredentials) 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](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/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:

![Verification Flow using the Browser's Digital Credentials API](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Mermaid_Chart_Create_complex_visual_diagrams_with_text_A_smarter_way_of_creating_diagrams_2025_07_24_233623_1b6ed9b957.svg)

**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](https://www.corbado.com/blog/digital-wallet-assurance) 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".

5. **Scan & Present:** The user scans the QR code with **CMWallet**. The wallet gets user
   approval and sends the [Verifiable Presentation](https://www.corbado.com/glossary/verifiable-presentation)
   back to the browser.
6. **Promise Resolution:** The browser receives the response, and the original `.get()`
   promise on the frontend finally resolves, delivering the presentation payload.
7. **Backend Verification:** The frontend **POSTs** the presentation payload to our
   backend's `/api/verify/finish` endpoint. The backend validates the nonce and
   credential.
8. **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.
>
> ```bash
> 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.

```bash
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.

```bash
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:

```yaml
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:

```sql
-- 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:

```bash
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](https://www.corbado.com/blog/react-passkeys) 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:

![NextJS Internal Architecture](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Mermaid_Chart_Create_complex_visual_diagrams_with_text_A_smarter_way_of_creating_diagrams_2025_07_25_091202_f96ccb049f.svg)

### 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:

```typescript
// 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](https://www.corbado.com/blog/react-passkeys)
boilerplate for state and UI rendering, which you can view in the
[GitHub repository](https://github.com/corbado/digital-credentials-example).

#### 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](https://www.w3.org/TR/digital-credentials/#the-digital-credentials-api).

- **`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

```typescript
// 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**
>
> • **`nonce`** – [cryptographic challenge](https://www.corbado.com/glossary/cryptographic-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

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```json
{
    "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:

```json
{
    "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:**

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

2. **Install Dependencies:**

    ```bash
    npm install
    ```

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

    ```bash
    docker-compose up -d
    ```

4. **Run the Application:**

    ```bash
    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](https://www.corbado.com/glossary/issuer) 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](https://www.w3.org/TR/did-resolution/).

- **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](https://www.w3.org/TR/vc-bitstring-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](https://owasp.org/www-project-api-security/) 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](https://www.corbado.com/blog/digital-identity-guide) (EUDI) PID credential. The
  [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-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.

## 6. 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](https://www.corbado.com/glossary/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.

## Frequently Asked Questions

### How do I manage session state securely in an OpenID4VP verifier?

A MySQL database with two tables handles session state: a `challenges` table storing
one-time nonces with 5-minute expiry, and a `verification_sessions` table tracking status
from pending through verified or failed. Docker Compose running MySQL 8.0, initialized via
an `init.sql` script, simplifies the setup.

### What is the difference between the desktop QR code flow and the mobile browser flow for the Digital Credentials API?

On desktop browsers, the Digital Credential API natively renders a QR code for the user to
scan with a compatible wallet app. On Android Chrome, enabling the
`chrome://flags#web-identity-digital-credentials` flag allows the browser to communicate
directly with on-device wallets, eliminating QR code scanning entirely.

### How do I test an OpenID4VP digital credential verifier without a real government-issued ID?

CMWallet, an OpenID4VP-compliant Android test wallet, ships pre-loaded with test
credentials compatible with the same OpenID4VP requests a production verifier sends.
Install it by downloading the APK from the official project repository and enabling
unknown app installation in Android security settings.

### What cryptographic validation does a production mDoc verifier require beyond basic CBOR decoding?

A production ISO mDoc verifier must validate the issuer's digital signature against their
public key (retrieved via DID resolution or a certificate endpoint) and check credential
revocation status against the issuer's revocation list. The placeholder check
(`mdocToken.length > 0`) must be replaced with full ISO 18013-5 signature verification.

### What does a DCQL query look like when requesting specific claims from a European Digital Identity mDoc credential?

A DCQL query specifies the credential format (`mso_mdoc`), the doctype (e.g.,
`eu.europa.ec.eudi.pid.1` for the European PID), and an array of claim paths. Requesting
`family_name`, `given_name` and `birth_date` targets exactly those fields from the
namespace without exposing the full credential to the verifier.

## 7. Resources

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

- **Project Repository:**
    - [Complete Source Code on GitHub](https://github.com/corbado/digital-credentials-example)

- **Key Specifications:**
    - [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/): The
      foundational standard for VCs.
    - [OpenID for Verifiable Presentations (OpenID4VP)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html):
      The presentation protocol used for the credential exchange.
    - [ISO/IEC 18013-5 (mDoc)](https://www.iso.org/standard/69084.html): The international
      standard for mobile Driver's Licenses (mDLs).
    - [W3C Digital Credentials API](https://www.w3.org/TR/digital-credentials/#the-digital-credentials-api):
      The browser API used to request credentials from a wallet.

- **Tools:**
    - [CMWallet for Android](https://github.com/digitalcredentialsdev/CMWallet/actions/runs/16407676816/artifacts/3574255220):
      The test wallet used in this guide.

- **Libraries:**
    - Next.js: The React framework for building the frontend and backend.
    - [cbor-web](https://github.com/hildjj/cbor-web): For decoding CBOR-encoded mdoc
      credentials.
    - [mysql2](https://github.com/sidorares/node-mysql2): The MySQL client for
      [Node.js](https://www.corbado.com/blog/nodejs-passkeys).
