Issue a Verifiable Credential
Issue a SD-JWT Verifiable Credential, signed by a did:cheqd Decentralized Identifier (DID), using Credo and OpenID4VCI.
Using the OpenID4VC module, you can issue OpenID compatible SD-JWT Credentials signed by a did:cheqd
identifier. This guide walks through the flow of issuance using the Credo Agent.
Prerequisites
Before you being, ensure you have:
Basic knowledge of Credo Agent configuration.
A cheqd testnet or mainnet account with sufficient tokens for DID operations.
Step 1: Install dependencies
npm install @credo-ts/core @credo-ts/node @credo-ts/cheqd
npm install @credo-ts/openid4vc
Step 2: Configure the Issuer Agent
Configure the issuer with cheqd and OpenID4VC Modules
import { Agent, DidsModule, KeyType } from '@credo-ts/core';
import { agentDependencies } from '@credo-ts/node';
import { CheqdModule } from '@credo-ts/cheqd';
import express, { Router } from 'express'
import { OpenId4VcIssuerModule, OpenId4VcVerifierModule } from '@credo-ts/openid4vc';
// Create two express routers, all endpoints for the
// issuer and verifier will be added to these routers
const verifierRouter = Router()
const issuerRouter = Router()
// Register the routers on the express server. The path should match
// with the baseUrl you configure in the modules below.
const app = express()
app.use('/oid4vci', issuerRouter)
app.use('/siop', verifierRouter)
const issuer = new Agent({
config,
dependencies: agentDependencies,
modules: {
dids: new DidsModule({
registrars: [new CheqdDidRegistrar()],
resolvers: [new CheqdDidResolver()],
}),
cheqd: new CheqdModule(
new CheqdModuleConfig({
networks: [
{
network: '<mainnet or testnet>',
cosmosPayerSeed: '<cosmos payer seed or mnemonic>',
},
],
})
),
openId4VcIssuer: new OpenId4VcIssuerModule({
baseUrl: 'https://your-issuer-host/oid4vci',
router: issuerRouter,
endpoints: {
// The credentialRequestToCredentialMapper is the only required endpoint
// configuration that must be provided. This method is called whenever a
// credential request has been received for an offer we created. The callback should
// return the issued credential to return in the credential response to the holder.
credential: {
// you'll map credential once requests come in
credentialRequestToCredentialMapper: async ({ credentialRequest }) => {
// See step 5.
},
},
},
}),
// openId4VcVerifier module can only be used in Node.js
openId4VcVerifier: new OpenId4VcVerifierModule({
baseUrl: 'https://your-issuer-host/siop',
router: verifierRouter,
}),
},
});
// listen on port 3000 for the openid4vc app.
app.listen(3000)
This registers:
cheqd
for did:cheqd DID creation and ledger integrationopenId4VcIssuer
andopenId4VcVerifier
for OID4VC flows
Step 3: Create a cheqd DID and link it to SD‑JWT issuance
// Create a did:cheqd that we will use for issuance
const issuerDidResult = await issuer.dids.create({
method: 'cheqd',
options: {
network: 'testnet',
methodSpecificIdAlgo: 'uuid',
},
})
if (issuerDidResult.didState.state !== 'finished') {
throw new Error('DID creation failed.')
}
const issuerDid = issuerDidResult.did;
Step 4: Define the Issuer with SD‑JWT + did:cheqd support
import { JwaSignatureAlgorithm } from '@credo-ts/core'
// Create an issuer with one supported credential: AcmeCorpEmployee
const openid4vcIssuer = await issuer.modules.openId4VcIssuer.createIssuer({
display: [
{
name: 'ACME Corp.',
description: 'ACME Corp. is a company that provides the best services.',
text_color: '#000000',
background_color: '#FFFFFF',
logo: {
url: 'https://acme.com/logo.png',
alt_text: 'ACME Corp. logo',
},
},
],
credentialsSupported: [
{
format: 'vc+sd-jwt',
vct: 'AcmeCorpEmployee',
id: 'AcmeCorpEmployee',
cryptographic_binding_methods_supported: ['did:cheqd'],
cryptographic_suites_supported: [JwaSignatureAlgorithm.ES256],
},
],
})
This declares support for:
vc+sd-jwt
formatcryptographic binding with wasm-bound
did:cheqd
keys
If you want to change the display metadata or the credentials supported by the issuer, you can use the issuer.modules.openId4VcIssuer.updateIssuer
method.
Step 5: Map credential requests to SD‑JWT payloads
Credential Issuance Mapper
The core of the issuance process is the credentialRequestToCredentialMapper
function. This function constructs the credential to be signed and issued to the Holder. It includes:
Domain-specific claims: e.g., employee ID, role.
Cryptographic bindings: Associations with the Holder.
Optional selective disclosure: Customisable elements for privacy control.
Utilising payload
and disclosureFrame
, you have full flexibility over the credential's structure and visibility. This allows for privacy-preserving credentials that adhere to the SD-JWT specification and the OpenID for Verifiable Credential Issuance (OID4VCI) standard.
const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = async ({
// agent context for the current wallet / tenant
agentContext,
// the credential offer related to the credential request
credentialOffer,
// the received credential request
credentialRequest,
// the list of credentialsSupported entries
credentialsSupported,
// the cryptographic binding provided by the holder in the credential request proof
holderBinding,
// the issuance session associated with the credential request and offer
issuanceSession,
}) => {
const firstSupported = credentialsSupported[0]
const { sub } = credentialRequest.claims;
const payload = {
vct: firstSupported.vct, // Verifiable Credential Type identifier
// Credential subject fields (flattened)
credentialSubjectId: sub, // Represents subject's DID (e.g., Holder DID)
firstName: 'John',
lastName: 'Doe',
employeeId: 'EMP-1234',
role: 'engineer',
// Optional: evidence and schema
evidence: [{
type: 'EmployeeRecord',
verifier: issuerDid,
evidenceDocument: 'HR Database Entry 2024-Q1',
subjectPresence: 'Physical',
documentPresence: 'Digital',
}],
credentialSchema: {
id: 'https://example.org/schemas/employee-passport.json',
type: 'JsonSchemaValidator2018',
},
// Credential Status
credentialStatus: {
id: 'https://status.cheqd.net/vc/123456',
type: 'StatusList2021Entry',
statusPurpose: 'revocation',
statusListIndex: '123456',
statusListCredential: 'https://status.cheqd.net/list/employee-vc.json',
},
// Timestamps in numeric format
notBefore: Math.floor(Date.now() / 1000),
expiry: Math.floor((Date.now() + 31536000000) / 1000),
// Terms of Use
termsOfUse: [
{
type: 'OdrlPolicy2017',
profile: 'https://cheqd.net/policies/employee-vc-policy.json',
prohibition: [
{
assigner: issuerDid,
target: 'credential',
action: 'share',
},
],
},
],
};
return {
credentialSupportedId: firstSupported.id,
format: 'vc+sd-jwt',
// We can provide the holderBinding as is, if we don't want to make changes
holder: holderBinding,
payload: payload,
disclosureFrame: {
_sd: ['lastName', 'credentialStatus', 'termsOfUse'],
},
issuer: {
method: 'cheqd',
issuerDid,
},
};
}
This constructs a standard SD‑JWT payload—structural claims ready for selective disclosure.
@context
Omitted in SD-JWT
Context is not typically included in JWT payloads
id
id
Use urn:uuid:...
or full URL
type
vct
Set via vct
(Verifiable Credential Type)
issuer
issuer
Must be a valid DID
issuanceDate
iat
ISO 8601 format
expirationDate
exp
Optional
credentialSubject
Flattened into individual keys
SD-JWT doesn’t nest claims
evidence
evidence
Optional, can be array of structured info
credentialSchema
credentialSchema
Helps verifier interpret structure
termsOfUse
termsOfUse
Optional. Encodes policy using ODRL or similar—can include rights, duties, and prohibitions.
With this expanded payload, you can also enhance your disclosureFrame
:
tsCopyEditdisclosureFrame: {
_sd: ['lastName', 'role', 'evidence', 'termsOfUse'],
}
This configuration means:
The
lastName
,role
,evidence
, andtermsOfUse
claims are blinded in the signed JWT.The Holder can choose to reveal these fields when presenting the credential.
While SD-JWT allows for simple flat key-value claims, some fields from the full VC model (like @context
, type
, and deeply nested credentialSubject
) are not directly represented due to JWT limitations. However, you can convey semantics through:
vct
(type semantics)credentialSchema
(structure enforcement)custom claim naming (e.g.,
credentialSubjectId
instead of nesting)Use
termsOfUse
to express legal/policy frameworks around credential usage, such as prohibitions on sharing.
Step 6: Create Credential Offer
Once you have configured the issuer, you can create a credential offer. The credential offer method will generate an offer URI that you can share with a holder.
const { credentialOffer, issuanceSession } =
await issuer.modules.openId4VcIssuer.createCredentialOffer({
issuerId: openid4vcIssuer.issuerId,
// values must match the `id` of the credential supported by the issuer
offeredCredentials: ['AcmeCorpEmployee'],
// Only pre-authorized code flow is supported
preAuthorizedCodeFlowConfig: {
userPinRequired: false,
},
// You can store any metadata about the issuance here
issuanceMetadata: {
someKey: 'someValue',
},
})
// Listen and react to changes in the issuance session
issuer.events.on<OpenId4VcIssuanceSessionStateChangedEvent>(
OpenId4VcIssuerEvents.IssuanceSessionStateChanged,
(event) => {
if (event.payload.issuanceSession.id === issuanceSession.id) {
console.log('Issuance session state changed to ',
event.payload.issuanceSession.state)
}
}
)
We have also added an event listener that listens for state changed events, this allows Issuer to know when the issuance session is complete.
Last updated
Was this helpful?