# W3C Verifiable Credentials

> Sign, store, present and verify W3C Verifiable Credentials (data model 1.1 and 2.0) as JWT, SD-JWT, JSON-LD or Data Integrity.

The [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model-2.0/) 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](https://credo.js.org/guides/next/protocols/openid4vc/w3c.md) and [W3C VCs with DIDComm](https://credo.js.org/guides/next/protocols/didcomm/w3c.md).

## Data models and securing mechanisms

| Data model | Secured as | Claim format | Selective disclosure | Holder binding | Exchange |
| --- | --- | --- | --- | --- | --- |
| 1.1 | JWT | `jwt_vc` (`jwt_vc_json` in OpenID4VC) | No | DID in `credentialSubject.id` | OpenID4VC, DIDComm presentation only |
| 1.1 | JSON-LD with an Ed25519 Linked Data proof | `ldp_vc` | No | DID in `credentialSubject.id` | DIDComm (OpenID4VC has known problems) |
| 2.0 | JWT | `vc+jwt` | No | `cnf` claim | None, sign and verify only |
| 2.0 | SD-JWT | `vc+sd-jwt` | Yes | `cnf` claim | OpenID4VC, DIDComm issuance |
| 2.0 | Data Integrity (`eddsa-jcs-2022`) | `di_vc` | No | DID in `credentialSubject.id` | DIDComm 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](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md) (`dc+sd-jwt`), even though both use SD-JWT. For the protocol details, see [W3C VCs with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/w3c.md) and [W3C VCs with DIDComm](https://credo.js.org/guides/next/protocols/didcomm/w3c.md).

### 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](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md) 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](https://credo.js.org/guides/next/agent-setup/storage.md) and key management. The examples use an issuer, a holder and a verifier, each with an Ed25519 `did:key`:

```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 `@context`s, 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

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

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

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

```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](https://credo.js.org/guides/next/dids-and-trust/trust.md) 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:

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

### Sign a credential

:::issuer

```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](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md#choose-the-holder-binding). A `di_vc` credential is bound through `credentialSubject.id`.
- `disclosureFrame` works as for [SD-JWT VC](https://credo.js.org/guides/next/credential-formats/sd-jwt-vc.md). `@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

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

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

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

```ts
const jwtVcResult = await verifier.w3cV2Credentials.verifyCredential({
  credential: jwtVc.encoded,
  trustedIssuers: [{ method: 'did', issuance: issuerDid.did }],
})
console.log(jwtVcResult.isValid)
```

:::

:::warning[Trust in 2.0]
- `trustedIssuers` on `verifyPresentation` isn't applied to the credentials in the presentation. Configure a [trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md) 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](https://credo.js.org/guides/next/credential-formats/status-lists.md).

## Next steps

- [W3C VCs with OpenID4VC](https://credo.js.org/guides/next/protocols/openid4vc/w3c.md)
- [W3C VCs with DIDComm](https://credo.js.org/guides/next/protocols/didcomm/w3c.md)
- [Trusted issuers](https://credo.js.org/guides/next/dids-and-trust/trust.md)
