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

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

Learn how to build a W3C Verifiable Credential issuer using OpenID4VCI protocol. This step-by-step guide shows you how to create a Next.js application that issues cryptographically signed credentials compatible with digital wallets.

Blog-Post-Author

Amine

Created: July 31, 2025

Updated: August 1, 2025


1. Introduction#

Digital Credentials 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 is a trusted entity-such as a government 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. 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 and store them securely in their digital wallets.

The end result will be a functional Next.js 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 for the user to scan with their mobile wallet.
  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. These can include simple digital certificates, basic digital badges, 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 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 protocol we're using is designed specifically for issuing Verifiable Credentials, and the JWT-VC format we'll implement is a W3C-compliant format for Verifiable Credentials.

1.2 How It Works#

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

  • Issuer: A trusted authority (e.g., a government agency, university, or bank) that cryptographically signs and issues a credential to a user. 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 on their device.
  • Verifier: An application or service that needs to check the user's credential.

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, 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.

Step 2: Credential Offer Generation The application generates a secure credential offer, displayed as a QR code and a pre-authorized code.

Step 3: Wallet Interaction The user scans the QR code with a compatible wallet (e.g., Sphereon Wallet) and enters a PIN to authorize the issuance.

Step 4: Credential Issued The wallet receives and stores the newly issued digital credential, ready for future use.

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 and issuing services. For this tutorial, we will focus on the following:

Standard / ProtocolDescription
OpenID4VCIOpenID 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-VCJWT-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 mDocISO/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.0The 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 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 website, as it provides a seamless, instant issuance experience without redirects.

  2. Authorization Code Flow: This is the standard OAuth 2.0 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 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 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 with React 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 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 application.
  • mysql2: A MySQL client for Node.js, 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 and iOS.

How to Install Sphereon Wallet:

  1. Download the wallet from the Google Play Store or the Apple App Store.
  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 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 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 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.
    • 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:

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.

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

4.1 Setting Up the Project#

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

4.1.1 Initializing the Next.js App#

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

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

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

4.1.2 Installing Dependencies#

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

npm install jose mysql2 uuid @types/uuid

This command installs:

  • jose: For signing and verifying JSON Web Tokens (JWTs).
  • 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 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:

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:

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

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:

// 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. 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.

// 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.

// 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 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.

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.

// 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.

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:

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

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

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

// 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.

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:

// 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.

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

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

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

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

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

    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:

    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:

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

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.

7. Resources#

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

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

Start for free

Share this article


LinkedInTwitterFacebook

Enjoyed this read?

🤝 Join our Passkeys Community

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

🚀 Subscribe to Substack

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

Related Articles