---
url: 'https://www.corbado.com/blog/how-to-build-verifiable-credential-issuer'
title: 'How to build a Digital Credential Issuer (Developer’s Guide)'
description: 'Learn how to build a W3C Verifiable Credential issuer via OpenID4VCI. This tutorial shows how to create a Next.js app that issues  VCs for digital wallets.'
lang: 'en'
author: 'Amine'
date: '2025-07-31T14:32:18.533Z'
lastModified: '2026-04-16T06:01:20.722Z'
keywords: 'digital credentials issuer, VC issuer, tutorial issuer, build issuer'
category: 'Digital Credentials'
---

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

## Key Facts

- **Building a W3C Verifiable Credential issuer** requires implementing the OpenID4VCI
  protocol with three core API endpoints: authorize, token and credential.
- The **pre-authorized code flow** generates a short-lived single-use code the wallet
  exchanges directly for a credential, removing OAuth redirects for users already
  authenticated on the issuer's site.
- Three **`.well-known` discovery endpoints** are required: openid-credential-issuer,
  openid-configuration and did.json, so wallets can find issuer capabilities and validate
  credential signatures.
- Credentials are signed using an Elliptic Curve key pair with the **ES256 algorithm**,
  producing a JWT-VC whose issuer public key is published via the did:web method.

## 1. Introduction

[Digital Credentials](https://www.corbado.com/blog/digital-credentials-api) are a powerful way to prove identity
and claims in a secure, privacy-preserving manner. But how do users get these credentials
in the first place? This is where the role of the **Issuer** becomes crucial. An
[Issuer](https://www.corbado.com/glossary/issuer) is a trusted entity-such as a
[government](https://www.corbado.com/passkeys-for-public-sector) agency, a university, or a bank-that is
responsible for creating and distributing digitally signed credentials to users.

This guide provides a comprehensive, step-by-step tutorial for building a Digital
Credential [Issuer](https://www.corbado.com/glossary/issuer). We will focus on the **OpenID for Verifiable
Credential Issuance (OpenID4VCI)** protocol, a modern standard that defines how users can
obtain credentials from an [Issuer](https://www.corbado.com/glossary/issuer) and store them securely in their
digital [wallets](https://www.corbado.com/blog/digital-wallet-assurance).

The end result will be a functional [Next.js](https://www.corbado.com/blog/nextjs-passkeys) application that can:

1. Accept user data through a simple web form.
2. Generate a secure, one-time credential offer.
3. Display the offer as a [QR code](https://www.corbado.com/blog/qr-code-login-authentication) for the user to
   scan with their mobile [wallet](https://www.corbado.com/blog/digital-wallet-assurance).
4. Issue a cryptographically signed credential that the user can store and present for
   verification.

### 1.1 Understanding the Terminology: Digital Credentials vs. Verifiable Credentials

Before we proceed, it's important to clarify the distinction between two related but
different concepts:

- **Digital Credentials (General Term):** This is a broad category that encompasses any
  digital form of credentials, certificates, or [attestations](https://www.corbado.com/glossary/attestation).
  These can include simple [digital certificates](https://www.corbado.com/glossary/microcredentials), basic
  [digital badges](https://www.corbado.com/glossary/digital-badge), or any electronically stored credential that
  may or may not have cryptographic security features.

- **Verifiable Credentials (VCs - W3C Standard):** This is a specific type of digital
  credential that follows the W3C [Verifiable Credentials](https://www.corbado.com/glossary/microcredentials)
  Data Model standard. Verifiable Credentials are cryptographically signed,
  tamper-evident, and privacy-respecting credentials that can be verified independently.
  They include specific technical requirements like:
    - Cryptographic signatures for authenticity and integrity
    - Standardized data model and formats
    - Privacy-preserving presentation mechanisms
    - Interoperable verification protocols

**In this guide, we are specifically building a Verifiable Credential issuer** that
follows the W3C standard, not just any digital credential system. The
[OpenID4VCI](https://www.corbado.com/glossary/openid4vci) protocol we're using is designed specifically for
issuing [Verifiable Credentials](https://www.corbado.com/glossary/microcredentials), and the JWT-VC format we'll
implement is a W3C-compliant format for
[Verifiable Credentials](https://www.corbado.com/glossary/microcredentials).

### 1.2 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. **This is the role we are building in this guide.**
- **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)

The issuance flow is the first step in this ecosystem. The Issuer validates the user's
information and provides them with a credential. Once the Holder has this credential in
their [wallet](https://www.corbado.com/blog/digital-wallet-assurance), they can present it to a Verifier to prove
their identity or claims, completing the triangle.

Here is a quick look at the final application in action:

**Step 1: User Data Input** The user fills out a form with their personal information to
request a new credential.
![User Data Input Form](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_1_0733a9e1da.png)

**Step 2: Credential Offer Generation** The application generates a secure credential
offer, displayed as a [QR code](https://www.corbado.com/blog/qr-code-login-authentication) and a pre-authorized
code.
![Credential Offer QR Code](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_2_3f1881c473.png)

**Step 3: Wallet Interaction** The user scans the
[QR code](https://www.corbado.com/blog/qr-code-login-authentication) with a compatible
[wallet](https://www.corbado.com/blog/digital-wallet-assurance) (e.g., Sphereon Wallet) and enters a PIN to
authorize the issuance.
![Credential Offer on wallet](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_3_b80d689dfe.png)
![PIN Code insertion](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_4_ca8bad8d11.png)

**Step 4: Credential Issued** The wallet receives and stores the newly issued digital
credential, ready for future use.
![Confirming the credential details](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_5_55b8150597.png)
![Credential added](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_6_7f5ac5745d.png)

## 2. Prerequisites to Build an Issuer

Before we dive into the code, let's cover the foundational knowledge and tools you'll
need. This guide assumes you have a basic familiarity with web development concepts, but
the following prerequisites are essential for building a credential issuer.

### 2.1 Protocol Choices

Our Issuer is built on a set of open standards that ensure interoperability between
[wallets](https://www.corbado.com/blog/digital-wallet-assurance) and issuing services. For this tutorial, we will
focus on the following:

| Standard / Protocol                                               | Description                                                                                                                                                                                                                      |
| :---------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenID4VCI**                                                    | **OpenID for Verifiable Credential Issuance.** This is the core protocol we will use. It defines a standard flow for how a user (via their wallet) can request and receive a credential from an Issuer.                          |
| **[JWT-VC](https://www.w3.org/TR/vc-data-model/#json-web-token)** | **JWT-based Verifiable Credentials.** The format for the credential we will issue. It is a W3C standard that encodes verifiable credentials as JSON Web Tokens (JWTs), making them compact and web-friendly.                     |
| **[ISO mDoc](https://www.iso.org/standard/69084.html)**           | **ISO/IEC 18013-5.** The international standard for mobile Driver's Licenses (mDLs). While we issue a JWT-VC, the _claims_ within it are structured to be compatible with the mDoc data model (e.g., `eu.europa.ec.eudi.pid.1`). |
| **OAuth 2.0**                                                     | The underlying authorization framework used by OpenID4VCI. We will implement a `pre-authorized_code` flow, which is a specific grant type designed for secure and user-friendly credential issuance.                             |

#### 2.1.1 Authorization Flows: Pre-Authorized vs. Authorization Code

[OpenID4VCI](https://www.corbado.com/glossary/openid4vci) supports two primary authorization flows for issuing
credentials:

1. **Pre-Authorized Code Flow:** In this flow, the Issuer generates a short-lived,
   single-use code (`pre-authorized_code`) that is immediately available to the user. The
   user's wallet can then exchange this code directly for a credential. This flow is ideal
   for scenarios where the user is already authenticated and present on the
   [Issuer's](https://www.corbado.com/glossary/issuer) website, as it provides a seamless, instant issuance
   experience without redirects.

2. **Authorization Code Flow:** This is the standard [OAuth 2.0](https://www.corbado.com/glossary/oauth2) flow,
   where the user is redirected to an authorization server to grant consent. After
   approval, the server sends an `authorization_code` back to a registered `redirect_uri`.
   This flow is more suitable for third-party applications that initiate the issuance
   process on behalf of the user.

**For this tutorial, we will use the `pre-authorized_code` flow.** We chose this approach
because it is simpler and provides a more direct user experience for our specific use
case: a user directly requesting a credential from the [Issuer's](https://www.corbado.com/glossary/issuer) own
website. It eliminates the need for complex redirects and client registration, making the
core issuance logic easier to understand and implement.

This combination of standards allows us to build an issuer that is compatible with a wide
range of digital [wallets](https://www.corbado.com/blog/digital-wallet-assurance) and ensures a secure,
standardized process for the user.

### 2.2 Tech Stack Choices

To build our issuer, we will use the same robust and modern tech stack that we used for
the verifier, ensuring a consistent and high-quality developer experience.

#### 2.2.1 Language: TypeScript

We'll use **TypeScript** for both our frontend and backend code. Its static typing is
invaluable in a security-critical application like an issuer, as it helps prevent common
errors and improves the overall quality and maintainability of the code.

#### 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 users can input their
  data to request a credential.
- **For the Backend:** We'll leverage **Next.js API Routes** to create the server-side
  endpoints that handle the [OpenID4VCI](https://www.corbado.com/glossary/openid4vci) flow, from generating
  credential offers to issuing the final signed credential.

#### 2.2.3 Key Libraries

Our implementation will rely on a few key libraries to handle specific tasks:

- **next**, **react**, and **react-dom**: The core libraries for our
  [Next.js](https://www.corbado.com/blog/nextjs-passkeys) application.
- **mysql2**: A [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) client for
  [Node.js](https://www.corbado.com/blog/nodejs-passkeys), used to store authorization codes and session data.
- **uuid**: A library for generating unique identifiers, which we'll use for creating
  `pre-authorized_code` values.
- **jose**: A robust library for handling JSON Web Signatures (JWS), which we will use to
  cryptographically sign the credentials we issue.

### 2.3 Get a Test Wallet

To test your issuer, you will need a mobile wallet that supports the OpenID4VCI protocol.
For this tutorial, we recommend the **Sphereon Wallet**, which is available for both
[Android](https://www.corbado.com/blog/how-to-enable-passkeys-android) and [iOS](https://www.corbado.com/blog/webauthn-errors).

**How to Install Sphereon Wallet:**

1. **Download the wallet** from the
   [Google Play Store](https://play.google.com/store/apps/details?id=com.sphereon.ssi.wallet)
   or the [Apple App Store](https://apps.apple.com/us/app/sphereon-wallet/id1661096796).
2. Install the app on your mobile device.
3. Once installed, the wallet is ready to receive credential offers by scanning a QR code.

### 2.4 Cryptography Knowledge

Issuing a credential is a security-critical operation that relies on fundamental
cryptographic concepts to ensure trust and authenticity.

#### 2.4.1 Digital Signatures

At its core, a [Verifiable Credential](https://www.corbado.com/glossary/verifiable-credential) is a set of claims
that has been **digitally signed** by the Issuer. This signature provides two guarantees:

- **Authenticity:** It proves that the credential was created by a legitimate Issuer.
- **Integrity:** It proves that the credential has not been tampered with since it was
  issued.

#### 2.4.2 Public/Private Key Cryptography

Digital signatures are created using public/private key cryptography. Here’s how it works:

1. **The Issuer has a key pair:** a **private key**, which is kept secret and secure, and
   a corresponding **public key**, which is made publicly available.
2. **Signing:** When the Issuer creates a credential, it uses its **private key** to
   generate a unique digital signature for the credential data.
3. **Verification:** A Verifier can later use 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 altered.

In our implementation, we will generate an Elliptic Curve (EC) key pair and use the
`ES256` algorithm to sign the JWT-VC. The public key is embedded in the Issuer's DID
(`did:web`), allowing any Verifier to discover it and validate the credential's
signature.\
**Note:** The `aud` (audience) claim is intentionally omitted in our JWTs, as the
credential is designed to be general-purpose and not bound to a specific wallet.\
If you want to restrict usage to a particular audience, include an `aud` claim and set it
accordingly.

## 3. Architectural Overview

Our Issuer application is built as a full-stack Next.js project, with a clear separation
between the frontend and backend logic. This architecture allows us to create a seamless
user experience while handling all the security-critical operations on the server.\
**Important:** The included `verification_sessions` and `verified_credentials` tables in
the SQL are not required for this issuer but are included for completeness.

- **Frontend (`src/app/issue/page.tsx`):** A single [React](https://www.corbado.com/blog/react-passkeys) page
  that allows users to input their data to request a credential. It makes API calls to our
  backend to initiate the issuance process.
- **Backend API Routes (`src/app/api/issue/...`):** A set of server-side endpoints that
  implement the OpenID4VCI protocol.
    - `/.well-known/openid-credential-issuer`: A public metadata endpoint. This is the
      first URL a wallet will check to discover the issuer's capabilities, including its
      authorization server, token endpoint, credential endpoint, and the types of
      credentials it offers.
    - `/.well-known/openid-configuration`: A standard OpenID Connect discovery endpoint.
      While closely related to the one above, this endpoint serves broader OIDC-related
      configuration and is often required for interoperability with standard OpenID
      clients.
    - `/.well-known/did.json`: The DID Document for our issuer. When using the `did:web`
      method, this file is used to publish the issuer's public keys, which verifiers can
      use to validate the signatures of the credentials it issues.
    - `authorize/route.ts`: Creates a `pre-authorized_code` and a credential offer.
    - `token/route.ts`: Exchanges the `pre-authorized_code` for an
      [access token](https://www.corbado.com/glossary/access-token).
    - `credential/route.ts`: Issues the final, cryptographically signed JWT-VC.
    - `schemas/pid/route.ts`: Exposes the JSON schema for the PID credential. This allows
      any consumer of the credential to understand its structure and data types.
- **Library (`src/lib/`):**
    - `database.ts`: Manages all database interactions, such as storing authorization
      codes and issuer keys.
    - `crypto.ts`: Handles all cryptographic operations, including key generation and JWT
      signing.

Here is a diagram illustrating the issuance flow:

![Digital Credential Issuance Flow](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_29_145228_d28fd13731.svg)

## 4. Building the Issuer

Now that we have a solid understanding of the standards, protocols, and architecture, we
can start building our issuer.

> **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 JWTs, database connections, and
UUID generation.

```bash
npm install jose mysql2 uuid @types/uuid
```

This command installs:

- `jose`: For signing and verifying JSON Web Tokens (JWTs).
- `mysql2`: The [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 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](https://www.corbado.com/blog/passkey-webauthn-database-guide) database to store
authorization codes, issuance sessions, and issuer keys. 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 for both the verifier and the issuer:

```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)
);

-- ISSUER TABLES

-- Table for storing authorization codes in OpenID4VCI flow
CREATE TABLE IF NOT EXISTS authorization_codes (
    id VARCHAR(36) PRIMARY KEY,
    code VARCHAR(255) NOT NULL UNIQUE,
    client_id VARCHAR(255),
    scope VARCHAR(255),
    code_challenge VARCHAR(255),
    code_challenge_method VARCHAR(50),
    redirect_uri TEXT,
    user_pin VARCHAR(10),
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    used BOOLEAN DEFAULT FALSE,
    INDEX idx_code (code),
    INDEX idx_expires_at (expires_at)
);

-- Table for storing issuance sessions
CREATE TABLE IF NOT EXISTS issuance_sessions (
    id VARCHAR(36) PRIMARY KEY,
    authorization_code_id VARCHAR(36),
    access_token VARCHAR(255),
    token_type VARCHAR(50) DEFAULT 'Bearer',
    expires_in INT DEFAULT 3600,
    c_nonce VARCHAR(255),
    c_nonce_expires_at TIMESTAMP,
    status ENUM('pending', 'authorized', 'credential_issued', 'expired', 'failed') DEFAULT 'pending',
    user_data JSON,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE,
    INDEX idx_access_token (access_token),
    INDEX idx_c_nonce (c_nonce),
    INDEX idx_status (status)
);

-- Table for storing issued credentials
CREATE TABLE IF NOT EXISTS issued_credentials (
    id VARCHAR(36) PRIMARY KEY,
    session_id VARCHAR(36),
    credential_id VARCHAR(255),
    credential_type VARCHAR(255) DEFAULT 'jwt_vc',
    doctype VARCHAR(255) DEFAULT 'eu.europa.ec.eudi.pid.1',
    credential_data LONGTEXT, -- Base64 encoded mDoc
    credential_claims JSON,
    issuer_did VARCHAR(255),
    subject_id VARCHAR(255),
    issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP,
    revoked BOOLEAN DEFAULT FALSE,
    revoked_at TIMESTAMP NULL,
    FOREIGN KEY (session_id) REFERENCES issuance_sessions(id) ON DELETE CASCADE,
    INDEX idx_credential_id (credential_id),
    INDEX idx_session_id (session_id),
    INDEX idx_doctype (doctype),
    INDEX idx_subject_id (subject_id),
    INDEX idx_issued_at (issued_at)
);

-- Table for storing issuer keys (simplified for demo)
CREATE TABLE IF NOT EXISTS issuer_keys (
    id VARCHAR(36) PRIMARY KEY,
    key_id VARCHAR(255) NOT NULL UNIQUE,
    key_type VARCHAR(50) NOT NULL, -- 'EC', 'RSA'
    algorithm VARCHAR(50) NOT NULL, -- 'ES256', 'RS256', etc.
    public_key TEXT NOT NULL, -- JWK format
    private_key TEXT NOT NULL, -- JWK format (encrypted in production)
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_key_id (key_id),
    INDEX idx_is_active (is_active)
);
```

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, ready for our application to
use.

### 4.2 Implementing the Shared Libraries

Before we build the API endpoints, let's create the shared libraries that will handle the
core business logic. This approach keeps our API routes clean and focused on handling HTTP
requests, while the complex work is delegated to these modules.

#### 4.2.1 The Database Library (`src/lib/database.ts`)

This file is the single source of truth for all database interactions. It uses the
`mysql2` library to connect to our MySQL container and provides a set of exported
functions for creating, reading, and updating records in our tables. This abstraction
layer makes our code more modular and easier to maintain.

Create the file `src/lib/database.ts` with the following content:

```typescript
// src/lib/database.ts
import mysql from "mysql2/promise";

// Database connection configuration
const dbConfig = {
    host: process.env.DATABASE_HOST || "localhost",
    port: parseInt(process.env.DATABASE_PORT || "3306"),
    user: process.env.DATABASE_USER || "app_user",
    password: process.env.DATABASE_PASSWORD || "app_password",
    database: process.env.DATABASE_NAME || "digital_credentials",
    timezone: "+00:00",
};

let connection: mysql.Connection | null = null;

export async function getConnection(): Promise<mysql.Connection> {
    if (!connection) {
        connection = await mysql.createConnection(dbConfig);
    }
    return connection;
}

// Data-Access-Object (DAO) functions for each table
// ... (e.g., createChallenge, getChallenge, createAuthorizationCode, etc.)
```

> **Note:** For brevity, the full list of DAO functions has been omitted. You can find the
> complete code in the
> [project repository](https://github.com/corbado/digital-credentials-example/blob/main/src/lib/database.ts).
> This file includes functions for managing challenges, verification sessions,
> authorization codes, issuance sessions, and issuer keys.

#### 4.2.2 The Crypto Library (`src/lib/crypto.ts`)

This file handles all security-critical cryptographic operations. It uses the `jose`
library to generate key pairs and sign JSON Web Tokens (JWTs).

**Key Generation** The `generateIssuerKeyPair` function creates a new Elliptic Curve key
pair that will be used to sign credentials. The public key is exported in JSON Web Key
(JWK) format so it can be published in our `did.json` document.

```typescript
// src/lib/crypto.ts
import { generateKeyPair, exportJWK, SignJWT } from "jose";

export async function generateIssuerKeyPair(keyId: string, issuerDid: string) {
    const { publicKey, privateKey } = await generateKeyPair("ES256", {
        crv: "P-256",
        extractable: true,
    });

    const publicKeyJWK = await exportJWK(publicKey);
    publicKeyJWK.kid = keyId; // Assign a unique key ID

    // ... (private key export and other setup)

    return { publicKey, privateKey, publicKeyJWK /* ... */ };
}
```

**JWT Credential Creation** The `createJWTVerifiableCredential` function is the core of
the issuance process. It takes the user's claims, the issuer's key pair, and other
metadata, and uses them to create a signed JWT-VC.

```typescript
// src/lib/crypto.ts

export async function createJWTVerifiableCredential(
    claims: MDocClaims,
    issuerKeyPair: IssuerKeyPair,
    subjectId: string,
    audience: string,
): Promise<string> {
    const now = Math.floor(Date.now() / 1000);
    const oneYear = 365 * 24 * 60 * 60;

    const vcPayload = {
        // The issuer's DID
        iss: issuerKeyPair.issuerDid,
        // The subject's (holder's) DID
        sub: subjectId,
        // The time the credential was issued (iat) and when it expires (exp)
        iat: now,
        exp: now + oneYear,
        // The Verifiable Credential data model
        vc: {
            "@context": [
                "https://www.w3.org/2018/credentials/v1",
                "https://europa.eu/eudi/pid/v1",
            ],
            type: ["VerifiableCredential", "eu.europa.ec.eudi.pid.1"],
            issuer: issuerKeyPair.issuerDid,
            issuanceDate: new Date(now * 1000).toISOString(),
            credentialSubject: {
                id: subjectId,
                ...claims,
            },
        },
    };

    // Sign the payload with the issuer's private key
    return await new SignJWT(vcPayload)
        .setProtectedHeader({
            alg: issuerKeyPair.algorithm,
            kid: issuerKeyPair.keyId,
            typ: "JWT",
        })
        .sign(issuerKeyPair.privateKey);
}
```

This function constructs the JWT payload according to the W3C Verifiable Credentials Data
Model and signs it with the issuer's private key, producing a secure and verifiable
credential.

### 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. This is achieved by leveraging the
App Router for both UI pages and API endpoints.

- **Frontend (`src/app/issue/page.tsx`):** A single [React](https://www.corbado.com/blog/react-passkeys) page
  component that defines the UI for the `/issue` route. It handles user input and
  communicates with our backend API.

- **Backend API Routes (`src/app/api/...`):**
    - **Discovery (`.well-known/.../route.ts`):** These routes expose public metadata
      endpoints that allow wallets and other clients to discover the issuer's capabilities
      and public keys.
    - **Issuance (`issue/.../route.ts`):** These endpoints implement the core OpenID4VCI
      logic, including creating credential offers, issuing tokens, and signing the final
      credential.
    - **Schema (`schemas/pid/route.ts`):** This route serves the JSON schema for the
      credential, defining its structure.

- **Library (`src/lib/`):** This directory contains reusable logic shared across the
  backend.
    - `database.ts`: Manages all database interactions, abstracting away SQL queries.
    - `crypto.ts`: Handles all cryptographic operations, such as key generation and JWT
      signing.

This clear separation makes the application modular and easier to maintain.

**Note:** The `generateIssuerDid()` function must return a valid `did:web` matching your
issuer domain.\
When deployed, the `.well-known/did.json` must be served over HTTPS at that domain for
verifiers to validate credentials.

![Architectural overview of Next.js App](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_29_151549_6a0aca6477.svg)

### 4.3 Building the Frontend

Our frontend is a single React page that provides a simple form for users to request a new
digital credential. Its responsibilities are to:

- Capture user data (name, date of birth, etc.).
- Send this data to our backend to create a credential offer.
- Display the resulting QR code and PIN for the user to scan with their wallet.

The core logic is handled in the `handleSubmit` function, which is triggered when the user
submits the form.

```typescript
// src/app/issue/page.tsx

const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    setCredentialOffer(null);

    try {
        // 1. Validate required fields
        if (!userData.given_name || !userData.family_name || !userData.birth_date) {
            throw new Error("Please fill in all required fields");
        }

        // 2. Request a credential offer from the backend
        const response = await fetch("/api/issue/authorize", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                user_data: userData,
            }),
        });

        if (!response.ok) {
            const errorData = await response.json();
            throw new Error(
                errorData.error_description || "Failed to create credential offer",
            );
        }

        // 3. Set the credential offer in state to display the QR code
        const result = await response.json();
        setCredentialOffer(result);
    } catch (err) {
        const errorMessage = (err as Error).message || "Unknown error occurred";
        setError(errorMessage);
    } finally {
        setLoading(false);
    }
};
```

This function performs three key actions:

1. **Validates the form data** to ensure all required fields are filled.
2. **Sends a `POST` request** to our `/api/issue/authorize` endpoint with the user's data.
3. **Updates the component's state** with the credential offer received from the backend,
   which triggers the UI to display the QR code and transaction code.

The rest of the file contains standard React code for rendering the form and the QR code
display. You can view the complete file in the
[project repository](https://github.com/corbado/digital-credentials-example/blob/main/src/app/issue/page.tsx).

### 4.4 Setting Up the Environment and Discovery

Before we build the backend API, we need to configure our environment and set up the
discovery endpoints. These `.well-known` files are crucial for wallets to find our issuer
and understand how to interact with it.

#### 4.4.1 Create the Environment File

Create a file named `.env.local` in the root of your project and add the following line.
This URL must be publicly accessible for a mobile wallet to reach it. For local
development, you can use a tunneling service like ngrok to expose your `localhost`.

```
NEXT_PUBLIC_BASE_URL=http://localhost:3000
```

#### 4.4.2 Implement the Discovery Endpoints

Wallets discover an issuer's capabilities by querying standard `.well-known` URLs. We need
to create three of these endpoints.

**1. Issuer Metadata (`/.well-known/openid-credential-issuer`)**

This is the primary discovery file for OpenID4VCI. It tells the wallet everything it needs
to know about the issuer, including its endpoints, the types of credentials it offers, and
the supported cryptographic algorithms.

Create the file `src/app/.well-known/openid-credential-issuer/route.ts`:

```typescript
// src/app/.well-known/openid-credential-issuer/route.ts
import { NextResponse } from "next/server";

export async function GET() {
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

    const issuerMetadata = {
        // The issuer's unique identifier.
        issuer: baseUrl,
        // The URL of the authorization server. For simplicity, our issuer is its own authorization server.
        authorization_servers: [baseUrl],
        // The URL of the credential issuer.
        credential_issuer: baseUrl,
        // The endpoint where the wallet will POST to receive the actual credential.
        credential_endpoint: `${baseUrl}/api/issue/credential`,
        // The endpoint where the wallet exchanges an authorization code for an access token.
        token_endpoint: `${baseUrl}/api/issue/token`,
        // The endpoint for the authorization flow (not used in our pre-authorized flow, but good practice to include).
        authorization_endpoint: `${baseUrl}/api/issue/authorize`,
        // Indicates support for the pre-authorized code flow without requiring client authentication.
        pre_authorized_grant_anonymous_access_supported: true,
        // Human-readable information about the issuer.
        display: [
            {
                name: "Corbado Credentials Issuer",
                locale: "en-US",
            },
        ],
        // A list of the credential types this issuer can issue.
        credential_configurations_supported: {
            "eu.europa.ec.eudi.pid.1": {
                // The format of the credential (e.g., jwt_vc, mso_mdoc).
                format: "jwt_vc",
                // The specific document type, conforming to ISO mDoc standards.
                doctype: "eu.europa.ec.eudi.pid.1",
                // The OAuth 2.0 scope associated with this credential type.
                scope: "eu.europa.ec.eudi.pid.1",
                // Methods the wallet can use to prove possession of its key.
                cryptographic_binding_methods_supported: ["jwk"],
                // Signing algorithms the issuer supports for this credential.
                credential_signing_alg_values_supported: ["ES256"],
                // Proof-of-possession types the wallet can use.
                proof_types_supported: {
                    jwt: {
                        proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"],
                    },
                },
                // Display properties for the credential.
                display: [
                    {
                        name: "Corbado Credential Issuer",
                        locale: "en-US",
                        logo: {
                            uri: `${baseUrl}/logo.png`,
                            alt_text: "EU Digital Identity",
                        },
                        background_color: "#003399",
                        text_color: "#FFFFFF",
                    },
                ],
                // A list of the claims (attributes) in the credential.
                claims: {
                    "eu.europa.ec.eudi.pid.1": {
                        given_name: {
                            mandatory: true,
                            display: [{ name: "Given Name", locale: "en-US" }],
                        },
                        family_name: {
                            mandatory: true,
                            display: [{ name: "Family Name", locale: "en-US" }],
                        },
                        birth_date: {
                            mandatory: true,
                            display: [{ name: "Date of Birth", locale: "en-US" }],
                        },
                    },
                },
            },
        },
        // Authentication methods supported by the token endpoint. 'none' means public client.
        token_endpoint_auth_methods_supported: ["none"],
        // PKCE code challenge methods supported.
        code_challenge_methods_supported: ["S256"],
        // OAuth 2.0 grant types the issuer supports.
        grant_types_supported: [
            "authorization_code",
            "urn:ietf:params:oauth:grant-type:pre-authorized_code",
        ],
    };

    return NextResponse.json(issuerMetadata, {
        headers: {
            "Content-Type": "application/json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            Pragma: "no-cache",
            Expires: "0",
        },
    });
}
```

**2. OpenID Configuration (`/.well-known/openid-configuration`)**

This is a standard OIDC discovery document that provides a broader set of configuration
details.

Create the file `src/app/.well-known/openid-configuration/route.ts`:

```typescript
// src/app/.well-known/openid-configuration/route.ts
import { NextResponse } from "next/server";

export async function GET() {
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

    const openidConfiguration = {
        // The issuer's unique identifier.
        credential_issuer: baseUrl,
        // The endpoint where the wallet will POST to receive the actual credential.
        credential_endpoint: `${baseUrl}/api/issue/credential`,
        // The endpoint for the authorization flow.
        authorization_endpoint: `${baseUrl}/api/issue/authorize`,
        // The endpoint where the wallet exchanges an authorization code for an access token.
        token_endpoint: `${baseUrl}/api/issue/token`,
        // A list of the credential types this issuer can issue.
        credential_configurations_supported: {
            "eu.europa.ec.eudi.pid.1": {
                format: "jwt_vc",
                scope: "eu.europa.ec.eudi.pid.1",
                cryptographic_binding_methods_supported: ["jwk"],
                credential_signing_alg_values_supported: ["ES256", "ES384", "ES512"],
                proof_types_supported: {
                    jwt: {
                        proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"],
                    },
                },
            },
        },
        // OAuth 2.0 grant types the issuer supports.
        grant_types_supported: [
            "authorization_code",
            "urn:ietf:params:oauth:grant-type:pre-authorized_code",
        ],
        // Indicates support for the pre-authorized code flow.
        pre_authorized_grant_anonymous_access_supported: true,
        // PKCE code challenge methods supported.
        code_challenge_methods_supported: ["S256"],
        // Authentication methods supported by the token endpoint.
        token_endpoint_auth_methods_supported: ["none"],
        // OAuth 2.0 scopes the issuer supports.
        scopes_supported: ["eu.europa.ec.eudi.pid.1"],
    };

    return NextResponse.json(openidConfiguration, {
        headers: {
            "Content-Type": "application/json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            Pragma: "no-cache",
            Expires: "0",
        },
    });
}
```

**3. DID Document (`/.well-known/did.json`)**

This file publishes the issuer's public key using the `did:web` method, allowing anyone to
verify the signature of credentials issued by it.

Create the file `src/app/.well-known/did.json/route.ts`:

```typescript
// src/app/.well-known/did.json/route.ts
import { NextResponse } from "next/server";
import { getActiveIssuerKey } from "../../../lib/database";
import { generateIssuerDid } from "../../../lib/crypto";

export async function GET() {
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
    const issuerKey = await getActiveIssuerKey();

    if (!issuerKey) {
        return NextResponse.json(
            { error: "No active issuer key found" },
            { status: 404 },
        );
    }

    const publicKeyJWK = JSON.parse(issuerKey.public_key);
    const didId = generateIssuerDid();
    const didDocument = {
        // The context defines the vocabulary used in the document.
        "@context": [
            "https://www.w3.org/ns/did/v1",
            "https://w3id.org/security/suites/jws-2020/v1",
        ],
        // The DID URI, which is the unique identifier for the issuer.
        id: didId,
        // The DID controller, which is the entity that controls the DID. Here, it's the issuer itself.
        controller: didId,
        // A list of public keys that can be used to verify signatures from the issuer.
        verificationMethod: [
            {
                // A unique identifier for the key, scoped to the DID.
                id: `${didId}#${issuerKey.key_id}`,
                // The type of the key.
                type: "JsonWebKey2020",
                // The DID of the key's controller.
                controller: didId,
                // The public key in JWK format.
                publicKeyJwk: publicKeyJWK,
            },
        ],
        // Specifies which keys can be used for authentication (proving control of the DID).
        authentication: [`${didId}#${issuerKey.key_id}`],
        // Specifies which keys can be used for creating verifiable credentials.
        assertionMethod: [`${didId}#${issuerKey.key_id}`],
        // A list of services provided by the DID subject, such as the issuer endpoint.
        service: [
            {
                id: `${didId}#openid-credential-issuer`,
                type: "OpenIDCredentialIssuer",
                serviceEndpoint: `${baseUrl}/.well-known/openid-credential-issuer`,
            },
        ],
    };

    return NextResponse.json(didDocument, {
        headers: {
            "Content-Type": "application/did+json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            Pragma: "no-cache",
            Expires: "0",
        },
    });
}
```

> **Why No Caching?** You'll notice that all three of these endpoints return headers that
> aggressively prevent caching (`Cache-Control: no-cache`, `Pragma: no-cache`,
> `Expires: 0`). This is a critical security practice for discovery documents. Issuer
> configurations can change-for example, a cryptographic key might be rotated. If a wallet
> or client were to cache an old version of the `did.json` or `openid-credential-issuer`
> file, it would fail to validate new credentials or interact with updated endpoints. By
> forcing clients to fetch a fresh copy on each request, we ensure they always have the
> most up-to-date information.

#### 4.4.3 Implement the Credential Schema Endpoint

The final piece of our public-facing infrastructure is the credential schema endpoint.
This route serves a JSON Schema that formally defines the structure, data types, and
constraints of the PID credential we are issuing. Wallets and verifiers can use this
schema to validate the credential's contents.

Create the file `src/app/api/schemas/pid/route.ts` with the following content:

```typescript
// src/app/api/schemas/pid/route.ts
import { NextResponse } from "next/server";

export async function GET() {
    const schema = {
        $schema: "https://json-schema.org/draft/2020-12/schema",
        $id: "https://example.com/schemas/pid", // Replace with your actual domain
        title: "PID Credential",
        description:
            "A schema for a Verifiable Credential representing a Personal Identification Document (PID).",
        type: "object",
        properties: {
            credentialSubject: {
                type: "object",
                properties: {
                    given_name: { type: "string" },
                    family_name: { type: "string" },
                    birth_date: { type: "string", format: "date" },
                    // ... other properties of the credential subject
                },
                required: ["given_name", "family_name", "birth_date"],
            },
            // ... other top-level properties of a Verifiable Credential
        },
    };

    return NextResponse.json(schema, {
        headers: {
            "Content-Type": "application/schema+json",
            "Access-Control-Allow-Origin": "*", // Allow cross-origin requests
        },
    });
}
```

> **Note:** The JSON Schema for a PID credential can be quite large and detailed. For
> brevity, the full schema has been truncated. You can find the complete file in the
> [project repository](https://github.com/corbado/digital-credentials-example/blob/main/src/app/api/schemas/pid/route.ts).

### 4.5 Building the Backend Endpoints

With the frontend in place, we now need the server-side logic to handle the OpenID4VCI
flow. We'll start with the first endpoint the frontend calls: `/api/issue/authorize`.

#### 4.5.1 `/api/issue/authorize`: Create the Credential Offer

This endpoint is responsible for taking the user's data, generating a secure one-time-use
code, and constructing a `credential_offer` that the user's wallet can understand.

Here's the core logic:

```typescript
// src/app/api/issue/authorize/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { createAuthorizationCode } from "@/lib/database";

export async function POST(request: NextRequest) {
    try {
        const body = await request.json();
        const { user_data } = body;

        // 1. Validate user data
        if (
            !user_data ||
            !user_data.given_name ||
            !user_data.family_name ||
            !user_data.birth_date
        ) {
            return NextResponse.json({ error: "missing_user_data" }, { status: 400 });
        }

        // 2. Generate a pre-authorized code and a PIN
        const code = uuidv4();
        const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
        const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // 4-digit PIN

        // 3. Store the code and user data
        await createAuthorizationCode(uuidv4(), code, expiresAt);
        // Note: This uses an in-memory store for demo purposes only.
        // In production, persist data securely in a database with proper expiry.
        if (!(global as any).userDataStore) (global as any).userDataStore = new Map();
        (global as any).userDataStore.set(code, user_data);
        if (!(global as any).txCodeStore) (global as any).txCodeStore = new Map();
        (global as any).txCodeStore.set(code, txCode);

        // 4. Create the credential offer object
        const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
        const credentialOffer = {
            // The issuer's identifier, which is its base URL.
            credential_issuer: baseUrl,
            // An array of credential types the issuer is offering.
            credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"],
            // Specifies the grant types the wallet can use.
            grants: {
                // We are using the pre-authorized code flow.
                "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
                    // The one-time code the wallet will exchange for a token.
                    "pre-authorized_code": code,
                    // Indicates that the user must enter a PIN (tx_code) to redeem the code.
                    user_pin_required: true,
                },
            },
        };

        // 5. Create the full credential offer URI (a deep link for wallets)
        const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent(
            JSON.stringify(credentialOffer),
        )}`;

        // The final response to the frontend.
        return NextResponse.json({
            // The deep link for the QR code.
            credential_offer_uri: credentialOfferUri,
            // The raw pre-authorized code, for display or manual entry.
            pre_authorized_code: code,
            // The 4-digit PIN the user must enter in their wallet.
            tx_code: txCode,
        });
    } catch (error) {
        console.error("Authorization error:", error);
        return NextResponse.json({ error: "server_error" }, { status: 500 });
    }
}
```

Key steps in this endpoint:

1. **Validate Data:** It first ensures the required user data is present.
2. **Generate Codes:** It creates a unique `pre-authorized_code` (a UUID) and a 4-digit
   `tx_code` (PIN) for an extra layer of security.
3. **Persist Data:** The `pre-authorized_code` is stored in the database with a short
   expiry time. The user's data and the PIN are stored in-memory, linked to the code.
4. **Build Offer:** It constructs the `credential_offer` object according to the
   OpenID4VCI specification. This object tells the wallet where the issuer is, what
   credentials it offers, and the code needed to get them.
5. **Return URI:** Finally, it creates a deep link URI (`openid-credential-offer://...`)
   and returns it to the frontend, along with the `tx_code` for the user to see.

#### 4.5.2 `/api/issue/token`: Exchange the Code for a Token

Once the user scans the QR code and enters their PIN, the wallet makes a `POST` request to
this endpoint. Its job is to validate the `pre-authorized_code` and the `user_pin` (PIN),
and if they are valid, issue a short-lived [access token](https://www.corbado.com/glossary/access-token).

Create the file `src/app/api/issue/token/route.ts` with the following content:

```typescript
// src/app/api/issue/token/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import {
    getAuthorizationCode,
    markAuthorizationCodeAsUsed,
    createIssuanceSession,
} from "@/lib/database";

export async function POST(request: NextRequest) {
    try {
        const formData = await request.formData();
        const grant_type = formData.get("grant_type") as string;
        const code = formData.get("pre-authorized_code") as string;
        const user_pin = formData.get("user_pin") as string;

        // 1. Validate the grant type
        if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") {
            return NextResponse.json(
                { error: "unsupported_grant_type" },
                { status: 400 },
            );
        }

        // 2. Validate the pre-authorized code
        const authCode = await getAuthorizationCode(code);
        if (!authCode) {
            return NextResponse.json(
                {
                    error: "invalid_grant",
                    error_description: "Invalid or expired code",
                },
                { status: 400 },
            );
        }

        // 3. Validate the PIN (tx_code)
        const expectedTxCode = (global as any).txCodeStore?.get(code);
        if (expectedTxCode !== user_pin) {
            return NextResponse.json(
                { error: "invalid_grant", error_description: "Invalid PIN" },
                { status: 400 },
            );
        }

        // 4. Generate access token and c_nonce
        const accessToken = uuidv4();
        const cNonce = uuidv4();
        const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes

        // 5. Create a new issuance session
        const userData = (global as any).userDataStore?.get(code);
        await createIssuanceSession(
            uuidv4(),
            authCode.id,
            accessToken,
            cNonce,
            cNonceExpiresAt,
            userData,
        );

        // 6. Mark the code as used and clean up temporary data
        await markAuthorizationCodeAsUsed(code);
        (global as any).txCodeStore?.delete(code);
        (global as any).userDataStore?.delete(code);

        // 7. Return the access token response
        return NextResponse.json({
            access_token: accessToken,
            token_type: "Bearer",
            expires_in: 3600, // 1 hour
            c_nonce: cNonce,
            c_nonce_expires_in: 300, // 5 minutes
        });
    } catch (error) {
        console.error("Token endpoint error:", error);
        return NextResponse.json({ error: "server_error" }, { status: 500 });
    }
}
```

Key steps in this endpoint:

1. **Validate Grant Type:** It ensures the wallet is using the correct
   `pre-authorized_code` grant type.
2. **Validate Code:** It checks that the `pre-authorized_code` exists in the database, is
   not expired, and has not been used before.
3. **Validate PIN:** It compares the `user_pin` from the wallet with the `tx_code` we
   stored earlier to ensure the user authorized the transaction.
4. **Generate Tokens:** It creates a secure `access_token` and a `c_nonce` (credential
   nonce), which is a one-time-use value to prevent replay attacks on the credential
   endpoint.
5. **Create Session:** It creates a new `issuance_sessions` record in the database,
   linking the [access token](https://www.corbado.com/glossary/access-token) to the user's data.
6. **Mark Code as Used:** To prevent the same offer from being used twice, it marks the
   `pre-authorized_code` as used.
7. **Return Token:** It returns the `access_token` and `c_nonce` to the wallet.

#### 4.5.3 `/api/issue/credential`: Issue the Signed Credential

This is the final and most important endpoint. The wallet uses the access token it
received from the `/token` endpoint to make an authenticated `POST` request to this route.
This endpoint's job is to perform the final validation, create the cryptographically
signed credential, and return it to the wallet.

Create the file `src/app/api/issue/credential/route.ts` with the following content:

```typescript
// src/app/api/issue/credential/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import {
    getIssuanceSessionByToken,
    updateIssuanceSession,
    createIssuedCredential,
    getActiveIssuerKey,
} from "@/lib/database";
import {
    createJWTVerifiableCredential,
    importIssuerKeyPair,
    generateIssuerDid,
} from "@/lib/crypto";

export async function POST(request: NextRequest) {
    try {
        // 1. Validate the Bearer token
        const authHeader = request.headers.get("authorization");
        const accessToken = authHeader?.substring(7);
        const session = await getIssuanceSessionByToken(accessToken);

        if (!session) {
            return NextResponse.json({ error: "invalid_token" }, { status: 401 });
        }

        // 2. Get the user data from the session
        const userData = session.user_data;
        if (!userData) {
            return NextResponse.json({ error: "missing_user_data" }, { status: 400 });
        }

        // 3. Get the active issuer key
        const issuerKey = await getActiveIssuerKey();
        if (!issuerKey) {
            // In a real application, you would have a more robust key management system.
            // For this demo, we can generate a key on the fly if one doesn't exist.
            // This part is omitted for brevity but is in the repository.
            return NextResponse.json(
                {
                    error: "server_error",
                    error_description: "Failed to get issuer key",
                },
                { status: 500 },
            );
        }

        // 4. Create the JWT-VC
        const issuerDid = generateIssuerDid();
        const keyPair = await importIssuerKeyPair(
            issuerKey.key_id,
            issuerKey.public_key,
            issuerKey.private_key,
            issuerDid,
        );
        const subjectId = `did:example:${uuidv4()}`;
        const credentialData = await createJWTVerifiableCredential(
            userData,
            keyPair,
            subjectId,
            process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000",
        );

        // 5. Store the issued credential in the database
        await createIssuedCredential(/* ... credential details ... */);
        await updateIssuanceSession(session.id, "credential_issued");

        // 6. Return the signed credential
        return NextResponse.json({
            format: "jwt_vc",
            credential: credentialData,
            c_nonce: uuidv4(), // A new nonce for subsequent requests
            c_nonce_expires_in: 300,
        });
    } catch (error) {
        console.error("Credential endpoint error:", error);
        return NextResponse.json({ error: "server_error" }, { status: 500 });
    }
}
```

Key steps in this endpoint:

1. **Validate Token:** It checks for a valid `Bearer` token in the `Authorization` header
   and uses it to look up the active issuance session.
2. **Retrieve User Data:** It retrieves the user's claims data, which was stored in the
   session when the token was created.
3. **Load Issuer Key:** It loads the issuer's active signing key from the database. In a
   real-world scenario, this would be managed by a secure key management system.
4. **Create Credential:** It calls our `createJWTVerifiableCredential` helper from
   `src/lib/crypto.ts` to construct and sign the JWT-VC.
5. **Log Issuance:** It saves a record of the issued credential in the database for
   auditing and revocation purposes.
6. **Return Credential:** It returns the signed credential to the wallet in a JSON
   response. The wallet is then responsible for securely storing it.

## 5. Running the Issuer and Next Steps

You now have a complete, end-to-end implementation of a digital credential issuer. Here’s
how to run it locally and what you need to consider 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, then start the MySQL container:

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

4. **Configure Environment & Run Tunnel:** This is the most critical step for local
   testing. Since your mobile wallet needs to connect to your development machine over the
   internet, you must expose your local server with a public HTTPS URL. We'll use `ngrok`
   for this.

    a. **Start ngrok:**

    ```bash
    ngrok http 3000
    ```

    b. **Copy the HTTPS URL** from the ngrok output (e.g.,
    `https://random-string.ngrok.io`). c. **Create a `.env.local` file** and set the URL:

    ```
    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
    ```

5. **Run the Application:**

    ```bash
    npm run dev
    ```

    Open your browser to `http://localhost:3000/issue`. You can now fill out the form, and
    the generated QR code will correctly point to your public ngrok URL, allowing your
    mobile wallet to connect and receive the credential.

### 5.2 The Importance of HTTPS and `ngrok`

Digital credential protocols are built with security as a top priority. For this reason,
wallets will almost always refuse to connect to an issuer over an insecure (`http://`)
connection. The entire process relies on a secure **HTTPS** connection, which is enabled
by an **SSL certificate**.

A tunnel service like `ngrok` solves both problems by creating a secure, public-facing
HTTPS URL (with a valid SSL certificate) that forwards all traffic to your local
development server.\
Wallets require HTTPS and will refuse to connect to insecure (`http://`) endpoints. This
is an essential tool for testing any web service that needs to interact with mobile
devices or external webhooks.

### 5.3 What's Out of Scope for This Tutorial

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

- **Production-Ready Security:** The issuer is for educational purposes. A production
  system would require a secure Key Management System (KMS) instead of storing keys in a
  database, robust error handling, rate-limiting, and comprehensive audit logging.
- **Credential Revocation:** This guide does not implement a mechanism for revoking issued
  credentials.\
  While the schema includes a `revoked` flag for future use, no revocation logic is
  provided here.
- **Authorization Code Flow:** We focused exclusively on the `pre-authorized_code` flow. A
  full implementation of the `authorization_code` flow would require a user consent screen
  and more complex [OAuth 2.0](https://www.corbado.com/glossary/oauth2) logic.
- **User Management:** The guide does not include any user authentication or management
  for the issuer itself. It is assumed that the user is already authenticated and
  authorized to receive a credential.

## 6. Conclusion

That's it! With a few pages of code, we now have a complete, end-to-end digital credential
issuer that:

1. Provides a user-friendly frontend for requesting credentials.
2. Implements the full OpenID4VCI `pre-authorized_code` flow.
3. Exposes all the necessary discovery endpoints for wallet interoperability.
4. Generates and signs a secure, standards-compliant
   JWT-[Verifiable Credential](https://www.corbado.com/glossary/verifiable-credential).

While this guide provides a solid foundation, a production-ready issuer would require
additional features like robust key management, persistent storage instead of in-memory
stores, credential revocation, and comprehensive security hardening.\
Wallet compatibility also varies; Sphereon Wallet is recommended for testing, but other
wallets may not support the pre-authorized flow as implemented here. However, the core
building blocks and the interaction flow would remain the same. By following these
patterns, you can build a secure and interoperable issuer for any type of digital
credential.

## Frequently Asked Questions

### Why do OpenID4VCI discovery endpoints need no-cache response headers?

Discovery documents like openid-credential-issuer and did.json must return Cache-Control:
no-cache, Pragma: no-cache and Expires: 0 headers. If a wallet caches an outdated did.json
after a key rotation, it will fail to validate new credentials or reach updated endpoints,
breaking the entire issuance flow.

### How do I expose a local OpenID4VCI issuer to a mobile wallet for testing?

Mobile wallets refuse to connect to insecure http\:// endpoints and require HTTPS with a
valid SSL certificate. Use ngrok to create a secure public-facing HTTPS URL that forwards
traffic to localhost:3000, then set that URL as NEXT_PUBLIC_BASE_URL in your .env.local
file before generating any QR codes.

### What is the difference between pre-authorized code flow and authorization code flow in OpenID4VCI?

The pre-authorized code flow issues a short-lived single-use code immediately, letting the
wallet exchange it directly for a credential without redirects. The authorization code
flow redirects the user to an authorization server and requires client registration and a
redirect URI, making it better suited for third-party applications initiating issuance on
behalf of a user.

### What npm libraries does a Next.js OpenID4VCI issuer require and what does each handle?

Four packages cover the core requirements: jose for cryptographically signing JSON Web
Tokens, mysql2 for storing authorization codes and issuance sessions, uuid for generating
unique pre-authorized codes and c_nonce values, and @types/uuid for TypeScript type
support. All four install together via a single npm install command.

## 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:**
    - [OpenID for Verifiable Credential Issuance (OpenID4VCI)](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html):
      The core issuance protocol.
    - [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/): The
      foundational standard for VCs.
    - [The `did:web` Method](https://w3c-ccg.github.io/did-method-web/): The DID method
      used for our issuer's public key.

- **Tools:**
    - [Sphereon Wallet](https://sphereon.com/wallet/): The test wallet used in this guide.
    - ngrok: For creating a secure tunnel to your local development environment.

- **Libraries:**
    - Next.js: The React framework for building the frontend and backend.
    - [jose](https://github.com/panva/jose): For creating and signing JSON Web Tokens
      (JWTs).
    - [mysql2](https://github.com/sidorares/node-mysql2): The MySQL client for
      [Node.js](https://www.corbado.com/blog/nodejs-passkeys).
