Skip to main content
Version: 0.7

W3C Verifiable Credentials

The W3C Verifiable Credentials Data Model defines a JSON structure for credentials and presentations. It doesn't fix how they're signed: the same credential can be secured as a JWT, an SD-JWT, or with an embedded proof. Credo supports data model 1.1 on agent.w3cCredentials and data model 2.0 on agent.w3cV2Credentials. This page covers signing, storing, presenting and verifying with both. For exchange, see W3C VCs with OpenID4VC and W3C VCs with DIDComm.

Data models and securing mechanisms​

Data modelSecured asClaim formatSelective disclosureHolder bindingExchange
1.1JWTjwt_vc (jwt_vc_json in OpenID4VC)NoDID in credentialSubject.idOpenID4VC, DIDComm presentation only
1.1JSON-LD with an Ed25519 Linked Data proofldp_vcNoDID in credentialSubject.idDIDComm (OpenID4VC has known problems)
2.0JWTvc+jwtNocnf claimNone, sign and verify only
2.0SD-JWTvc+sd-jwtYescnf claimOpenID4VC, DIDComm issuance
2.0Data Integrity (eddsa-jcs-2022)di_vcNoDID in credentialSubject.idDIDComm issuance

The issuer always signs with a key of a DID it created (or imported with its keys). X.509 issuers aren't supported. W3C 2.0 vc+sd-jwt is a different format from SD-JWT VC (dc+sd-jwt), even though both use SD-JWT. For the protocol details, see W3C VCs with OpenID4VC and W3C VCs with DIDComm.

Choose a variant​

Use the variant your ecosystem or verifiers ask for. Without such a requirement:

  • Over OpenID4VC, use data model 1.1 as JWT (jwt_vc_json). Use 2.0 as SD-JWT (vc+sd-jwt) when you need selective disclosure.
  • Over DIDComm, use 1.1 as JSON-LD (ldp_vc), the only 1.1 variant that DIDComm can issue, or 2.0 as SD-JWT for selective disclosure.
  • Avoid 2.0 as plain JWT and with Data Integrity. Credo can't exchange vc+jwt over any protocol, can't present di_vc over any protocol, and doesn't check the issuer trust and validity period of di_vc credentials.

If you don't need W3C VCs, SD-JWT VC is simpler and has selective disclosure.

Set up​

Everything on this page is in @credo-ts/core, and both modules are default modules. The agent also needs storage and key management. The examples use an issuer, a holder and a verifier, each with an Ed25519 did:key:

w3c.ts
import {
Agent,
ClaimFormat,
DidKey,
defaultDocumentLoader,
JsonTransformer,
type KeyDidCreateOptions,
purposes,
utils,
W3cCredential,
W3cCredentialRecord,
W3cCredentialsModule,
W3cJsonLdVerifiablePresentation,
w3cDate,
} from '@credo-ts/core'
import { agentDependencies } from '@credo-ts/node'

const membershipContext = {
'@context': {
MembershipCredential: 'https://example.com/vocab#MembershipCredential',
name: 'https://schema.org/name',
memberSince: { '@id': 'https://example.com/vocab#memberSince', '@type': 'http://www.w3.org/2001/XMLSchema#date' },
},
}

function createAgent(storage: ExampleStorage) {
return new Agent({
dependencies: agentDependencies,
modules: {
...storage, // Storage and key management, see Agent setup
// Serve your own JSON-LD contexts instead of fetching them from the network
w3cCredentials: new W3cCredentialsModule({
documentLoader: (agentContext) =>
defaultDocumentLoader(agentContext, { 'https://example.com/contexts/membership/v1': membershipContext }),
}),
},
})
}

async function createDidKey(agent: Agent) {
const { didState } = await agent.dids.create<KeyDidCreateOptions>({
method: 'key',
options: { createKey: { type: { kty: 'OKP', crv: 'Ed25519' } } },
})
if (didState.state !== 'finished') throw new Error('Creating the DID failed')
return { did: didState.did, didUrl: `${didState.did}#${DidKey.fromDid(didState.did).publicJwk.fingerprint}` }
}

JSON-LD contexts​

Every property of a JSON-LD credential must be defined in one of its @contexts, and signing and verifying load those contexts through a document loader. The default loader has copies of the common contexts (credentials v1 and v2, the security suites, DIDs, ODRL, schema.org, Open Badges 3.0), resolves DIDs, and fetches every other URL from the network. Pass your own contexts to defaultDocumentLoader as shown above, or set your own documentLoader to control what is fetched. Register W3cCredentialsModule under the w3cCredentials key to replace the default module. Data model 2.0 uses the same document loader.

Data model 1.1​

Sign a credential​

w3c.ts
const issuerDid = await createDidKey(issuer)
const holderDid = await createDidKey(holder)

const credential = W3cCredential.fromJson({
'@context': ['https://www.w3.org/2018/credentials/v1', 'https://example.com/contexts/membership/v1'],
type: ['VerifiableCredential', 'MembershipCredential'],
issuer: issuerDid.did,
issuanceDate: w3cDate(Date.now()),
// The subject id binds the credential to the holder DID
credentialSubject: { id: holderDid.did, name: 'Jane Doe', memberSince: '2024-01-01' },
})

const jwtVc = await issuer.w3cCredentials.signCredential<ClaimFormat.JwtVc>({
format: ClaimFormat.JwtVc,
credential,
alg: 'EdDSA',
verificationMethod: issuerDid.didUrl,
})
console.log(jwtVc.serializedJwt)

const ldpVc = await issuer.w3cCredentials.signCredential<ClaimFormat.LdpVc>({
format: ClaimFormat.LdpVc,
credential,
// did:key Ed25519 keys are Ed25519VerificationKey2018 verification methods
proofType: 'Ed25519Signature2018',
verificationMethod: issuerDid.didUrl,
})
console.log(ldpVc.toJson())
  • verificationMethod is the DID URL of an assertionMethod key of the issuer DID.
  • For jwt_vc, alg must fit the key. A JWT VC can have only one credentialSubject.
  • For ldp_vc, proofType is Ed25519Signature2018 for Ed25519VerificationKey2018 verification methods (such as did:key) or Ed25519Signature2020 for Ed25519VerificationKey2020. These are the only Linked Data suites. There is no BBS+ suite, so 1.1 credentials have no selective disclosure.
caution

Build JSON-LD credentials with W3cCredential.fromJson, as above. A new W3cCredential({ ... }) without expirationDate fails to sign as ldp_vc with "expirationDate" must be a valid date: undefined.

Store and query​

Holder
w3c.ts
const jwtResult = await holder.w3cCredentials.verifyCredential({ credential: jwtVc.serializedJwt })
const ldpResult = await holder.w3cCredentials.verifyCredential({ credential: ldpVc })
if (!jwtResult.isValid || !ldpResult.isValid) throw new Error('Received an invalid credential')

await holder.w3cCredentials.store({ record: W3cCredentialRecord.fromCredential(jwtVc) })
await holder.w3cCredentials.store({ record: W3cCredentialRecord.fromCredential(ldpVc) })

// Returns credentials, not records
const memberships = await holder.w3cCredentials.findAllByQuery({
types: ['MembershipCredential'],
claimFormat: ClaimFormat.LdpVc,
})
console.log(memberships.length)

findAllByQuery returns the credentials, not the records. Records are tagged with claimFormat, types, issuerId, subjectIds, schemaIds and contexts. getById and getAll return records, and deleteById removes one. There is no update. Store JSON-LD credentials with agent.w3cCredentials.store: it adds the expanded types that OpenID4VP queries on.

Present​

The holder wraps credentials in a presentation and signs it with an authentication key of its DID, the DID in credentialSubject.id:

Holder
w3c.ts
// Received from the verifier
const challenge = 'c0ffee12-3456-4789-abcd-ef0123456789'
const domain = 'https://verifier.example.com'

const jwtPresentation = await holder.w3cCredentials.createPresentation({
credentials: [jwtVc],
holder: holderDid.did,
})
const jwtVp = await holder.w3cCredentials.signPresentation<ClaimFormat.JwtVp>({
format: ClaimFormat.JwtVp,
presentation: jwtPresentation,
alg: 'EdDSA',
verificationMethod: holderDid.didUrl,
challenge,
domain,
})

const ldpPresentation = await holder.w3cCredentials.createPresentation({
// Signing a JSON-LD presentation without an id fails
id: `urn:uuid:${utils.uuid()}`,
credentials: [ldpVc],
holder: holderDid.did,
})
const ldpVp = await holder.w3cCredentials.signPresentation<ClaimFormat.LdpVp>({
format: ClaimFormat.LdpVp,
presentation: ldpPresentation,
proofType: 'Ed25519Signature2018',
proofPurpose: new purposes.AuthenticationProofPurpose({ challenge, domain }),
verificationMethod: holderDid.didUrl,
challenge,
domain,
})
  • challenge and domain come from the verifier. In a JWT VP they're the nonce and aud claims.
  • A JWT VP can only contain JWT VCs, and a JSON-LD VP only JSON-LD VCs.
  • A JSON-LD presentation needs a proofPurpose, and an id: signing fails without one.

Verify​

Verifier
w3c.ts
const jwtVpResult = await verifier.w3cCredentials.verifyPresentation({
presentation: jwtVp.serializedJwt,
challenge,
domain,
trustedIssuers: [{ method: 'did', issuance: issuerDid.did }],
})

// A JSON-LD presentation must be passed as an instance, not as JSON
const ldpVpResult = await verifier.w3cCredentials.verifyPresentation({
presentation: JsonTransformer.fromJSON(ldpVp.toJson(), W3cJsonLdVerifiablePresentation),
challenge,
domain,
trustedIssuers: [{ method: 'did', issuance: issuerDid.did }],
})

if (!jwtVpResult.isValid) console.error(jwtVpResult.error, jwtVpResult.validations)
console.log(jwtVpResult.isValid, ldpVpResult.isValid)

verifyCredential and verifyPresentation return { isValid, validations, error }, where validations shows which check failed. For a presentation, Credo checks the holder signature, challenge and domain, every credential in it, and that the holder is the subject of each credential. A JSON-LD credential or presentation must be passed as an instance, such as from JsonTransformer.fromJSON. The date checks of JSON-LD credentials don't use the agent's validitySkewSeconds.

Without trustedIssuers and without a trusted issuers callback on the agent, any issuer DID is accepted.

Data model 2.0​

The 2.0 examples use the same agents and DIDs, and these imports:

w3c-v2.ts
import {
Agent,
ClaimFormat,
DidKey,
type KeyDidCreateOptions,
W3cV2Credential,
W3cV2CredentialRecord,
W3cV2EnvelopedVerifiableCredential,
W3cV2Presentation,
W3cV2SdJwtCredentialService,
w3cDate,
} from '@credo-ts/core'

Sign a credential​

Issuer
w3c-v2.ts
const credential = new W3cV2Credential({
type: ['VerifiableCredential', 'MembershipCredential'],
issuer: issuerDid.did,
validFrom: w3cDate(Date.now()),
credentialSubject: { id: holderDid.did, name: 'Jane Doe', memberSince: '2024-01-01' },
})

// vc+jwt
const jwtVc = await issuer.w3cV2Credentials.signCredential<ClaimFormat.JwtW3cVc>({
format: ClaimFormat.JwtW3cVc,
credential,
alg: 'EdDSA',
verificationMethod: issuerDid.didUrl,
})

// vc+sd-jwt: `cnf` binds the credential to the holder key
const sdJwtVc = await issuer.w3cV2Credentials.signCredential<ClaimFormat.SdJwtW3cVc>({
format: ClaimFormat.SdJwtW3cVc,
credential,
alg: 'EdDSA',
verificationMethod: issuerDid.didUrl,
holder: { method: 'did', didUrl: holderDid.didUrl },
disclosureFrame: { credentialSubject: { _sd: ['name', 'memberSince'] } },
})

// di_vc with a Data Integrity proof
const diVc = await issuer.w3cV2Credentials.signCredential<ClaimFormat.DiVc>({
format: ClaimFormat.DiVc,
credential,
cryptosuite: 'eddsa-jcs-2022',
verificationMethod: issuerDid.didUrl,
})

// `encoded` is a compact string for vc+jwt and vc+sd-jwt, and JSON for di_vc
console.log(sdJwtVc.encoded, diVc.encoded)
  • verificationMethod is the DID URL of an assertionMethod key of the issuer DID, and alg must fit the key.
  • holder adds a cnf claim with a DID URL or a JWK, for vc+jwt and vc+sd-jwt. Choose as for SD-JWT VC. A di_vc credential is bound through credentialSubject.id.
  • disclosureFrame works as for SD-JWT VC. @context, type, credentialStatus, credentialSchema and relatedResource can't be selectively disclosable at the top level.
  • cryptosuite for di_vc is eddsa-jcs-2022, the only cryptosuite, which needs an Ed25519 key.

Store and query​

Holder
w3c-v2.ts
const sdJwtResult = await holder.w3cV2Credentials.verifyCredential({ credential: sdJwtVc.encoded })
const diResult = await holder.w3cV2Credentials.verifyCredential({ credential: diVc })
if (!sdJwtResult.isValid || !diResult.isValid) throw new Error('Received an invalid credential')

const record = await holder.w3cV2Credentials.store({ record: W3cV2CredentialRecord.fromCredential(sdJwtVc) })

// Returns credentials, not records
const memberships = await holder.w3cV2Credentials.findAllByQuery({
claimFormat: ClaimFormat.SdJwtW3cVc,
types: ['MembershipCredential'],
})
console.log(memberships.length)

As in 1.1, findAllByQuery returns credentials and there is no update. A record can hold several instances of the same credential for batch issuance.

Present​

There is no key binding JWT for 2.0. The holder proves possession by signing a presentation that contains the credential:

Holder
w3c-v2.ts
// Received from the verifier
const challenge = '9d0e4c6a-2b1f-4e8d-a7c3-5f6b8e9d0a1c'
const domain = 'https://verifier.example.com'

// Selective disclosure is only available on the service
const sdJwtService = holder.dependencyManager.resolve(W3cV2SdJwtCredentialService)
const disclosed = await sdJwtService.present(holder.context, {
credential: record.encoded,
presentationFrame: { credentialSubject: { name: true } },
})

const sdJwtVp = await holder.w3cV2Credentials.signPresentation<ClaimFormat.SdJwtW3cVp>({
format: ClaimFormat.SdJwtW3cVp,
presentation: new W3cV2Presentation({
holder: holderDid.did,
verifiableCredential: [W3cV2EnvelopedVerifiableCredential.fromVerifiableCredential(disclosed)],
}),
challenge,
domain,
})

const diVp = await holder.w3cV2Credentials.signPresentation<ClaimFormat.DiVp>({
format: ClaimFormat.DiVp,
presentation: new W3cV2Presentation({ holder: holderDid.did, verifiableCredential: [diVc] }),
cryptosuite: 'eddsa-jcs-2022',
verificationMethod: holderDid.didUrl,
challenge,
domain,
})
  • Selective disclosure for vc+sd-jwt is only on W3cV2SdJwtCredentialService. Wrap the result with W3cV2EnvelopedVerifiableCredential to put it in a presentation.
  • For vp+jwt and vp+sd-jwt, Credo signs with the authentication key of the holder DID. The DID must be created in this agent, with exactly one authentication key in its KMS. Without holder, Credo signs with the key in the credentials' cnf.
  • For di_vp, pass the verificationMethod and cryptosuite.

Verify​

Verifier
w3c-v2.ts
const sdJwtVpResult = await verifier.w3cV2Credentials.verifyPresentation({
presentation: sdJwtVp.encoded,
challenge,
domain,
})
const diVpResult = await verifier.w3cV2Credentials.verifyPresentation({ presentation: diVp, challenge, domain })

if (!sdJwtVpResult.isValid) console.error(sdJwtVpResult.presentation.validations, sdJwtVpResult.credentialEntries)
console.log(sdJwtVpResult.isValid, diVpResult.isValid)

The result has presentation.validations for the presentation itself and credentialEntries with a result per credential. verifyCredential verifies a single credential, and takes trustedIssuers:

Verifier
w3c-v2.ts
const jwtVcResult = await verifier.w3cV2Credentials.verifyCredential({
credential: jwtVc.encoded,
trustedIssuers: [{ method: 'did', issuance: issuerDid.did }],
})
console.log(jwtVcResult.isValid)
Trust in 2.0
  • trustedIssuers on verifyPresentation isn't applied to the credentials in the presentation. Configure a trusted issuers callback on the agent instead.
  • For di_vc credentials, Credo checks neither trusted issuers nor validFrom and validUntil. Check them yourself.

Status and revocation​

Credo doesn't verify credentialStatus for W3C credentials. There is no support for Bitstring Status List or Status List 2021.

  • Data model 1.1: verifying a credential with a credentialStatus, or a presentation that contains one, fails by default. Pass verifyCredentialStatus: false and check the status yourself.
  • Data model 2.0: credentialStatus is ignored, unless you pass verifyCredentialStatus: true, which makes verification fail.

For revocation with SD-JWT VC and mdoc, see Status lists.

Next steps​