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.
Amine
Created: July 31, 2025
Updated: August 1, 2025
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:
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:
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.
The magic behind digital credentials lies in a simple but powerful "trust triangle" model involving three key players:
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.
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.
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 / 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 | 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 | 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. |
OpenID4VCI supports two primary authorization flows for issuing credentials:
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.
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.
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.
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.
Next.js is our framework of choice because it provides a seamless, integrated experience for building full-stack applications.
Our implementation will rely on a few key libraries to handle specific tasks:
pre-authorized_code
values.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:
Issuing a credential is a security-critical operation that relies on fundamental cryptographic concepts to ensure trust and authenticity.
At its core, a Verifiable Credential is a set of claims that has been digitally signed by the Issuer. This signature provides two guarantees:
Digital signatures are created using public/private key cryptography. Here’s how it works:
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.
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.
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.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.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:
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
First, we'll initialize a new Next.js project, install the necessary dependencies, and start our database.
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.
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.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.
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.
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.
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.
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/...
):
.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.issue/.../route.ts
): These endpoints implement the core OpenID4VCI
logic, including creating credential offers, issuing tokens, and signing the final
credential.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.
Our frontend is a single React page that provides a simple form for users to request a new digital credential. Its responsibilities are to:
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:
POST
request to our /api/issue/authorize
endpoint with the user's data.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.
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.
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
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.
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.
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
.
/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:
pre-authorized_code
(a UUID) and a 4-digit
tx_code
(PIN) for an extra layer of security.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.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.openid-credential-offer://...
)
and returns it to the frontend, along with the tx_code
for the user to see./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:
pre-authorized_code
grant type.pre-authorized_code
exists in the database, is
not expired, and has not been used before.user_pin
from the wallet with the tx_code
we
stored earlier to ensure the user authorized the transaction.access_token
and a c_nonce
(credential
nonce), which is a one-time-use value to prevent replay attacks on the credential
endpoint.issuance_sessions
record in the database,
linking the access token to the user's data.pre-authorized_code
as used.access_token
and c_nonce
to the wallet./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:
Bearer
token in the Authorization
header
and uses it to look up the active issuance session.createJWTVerifiableCredential
helper from
src/lib/crypto.ts
to construct and sign the JWT-VC.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.
Clone the Repository:
git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
Install Dependencies:
npm install
Start the Database: Make sure Docker is running, then start the MySQL container:
docker-compose up -d
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>
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.
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.
This example is intentionally focused on the core issuance flow to make it easy to understand. The following topics are considered out of scope:
revoked
flag for future use, no revocation logic is
provided here.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.That's it! With a few pages of code, we now have a complete, end-to-end digital credential issuer that:
pre-authorized_code
flow.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.
Here are some of the key resources, specifications, and tools used or referenced in this tutorial:
Project Repository:
Key Specifications:
did:web
Method: The DID method
used for our issuer's public key.Tools:
Libraries:
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
Table of Contents