For AI agents: Documentation index at /llms.txt

Skip to content

Verifiable Credentials

A verifiable credential (VC) is a cryptographically signed digital attestation about a user: for example, that they are over 18, passed KYC, or are a member of an organization. On ICP, verifiable credentials are issued by canister-based issuers, mediated by Internet Identity, and consumed by relying party applications.

This guide covers the VC architecture on ICP, how the protocol works, and how to implement both sides of the flow: issuer and relying party.

Choose your path: If you are building a service that attests claims about users (age verification, KYC, membership), go to Implementing an issuer. If you are building an app that requests credentials from an issuer to gate access, go to Implementing a relying party.

The VC protocol on ICP involves four actors:

  • User: the person who holds the credential and consents to share it.
  • Issuer: a canister (or service) that verifies claims about a user and issues credentials. Examples: an age verification service, an employer, a KYC provider.
  • Relying party: a canister or application that requests credentials from an issuer to gate access or provide personalized experiences.
  • Identity provider: Internet Identity, which acts as the communication bridge between the relying party and the issuer. Critically, II creates a temporary id_alias identifier so the issuer and relying party never learn each other’s user principal: preserving unlinkability.

The flow always runs through Internet Identity: the relying party requests a credential, II prompts the user for consent, II contacts the issuer, and the resulting signed credential is returned to the relying party. The issuer and relying party communicate only through II: they never exchange data directly.

  1. The user visits the relying party and triggers a credential request (for example, by trying to access a members-only feature).
  2. The relying party opens an Internet Identity window at the /vc-flow path.
  3. II shows the user a consent dialog that identifies the relying party, the issuer, and the requested credential type.
  4. If the user approves, II creates an id_alias: an opaque temporary identifier unique to this RP/issuer pair.
  5. II calls the issuer’s prepare_credential and get_credential endpoints. The issuer returns a signed JWT credential bound to the id_alias.
  6. II returns a verifiable presentation (VP) to the relying party. The VP contains two nested JWTs:
    • An id-alias credential signed by II, proving that the relying party’s user principal maps to the id_alias.
    • The issued credential signed by the issuer, bound to the id_alias.
  7. The relying party verifies both signatures and the credential claims.

The two-credential structure is what preserves unlinkability: the issuer signs for the id_alias, not for the relying party’s principal. The relying party can verify the credential chain without learning the user’s identity at the issuer.

The relying party and Internet Identity communicate through window.postMessage(). When the II window is ready, it sends:

{
"jsonrpc": "2.0",
"method": "vc-flow-ready"
}

The relying party then sends a request_credential message (see Relying party below).

An issuer is a canister that exposes four API endpoints. Internet Identity calls these endpoints on behalf of users during the VC flow. The issuer never opens connections itself: it responds to calls from II.

Returns the consent text shown to the user in the II dialog. This message must clearly describe what credential is being requested and why.

// vc_consent_message: returns human-readable consent text for the user.
// Called by II before showing the consent dialog.
// Input: CredentialSpec { credentialType, arguments }
// Output: Icrc21ConsentInfo { consent_message, language }

Returns the URL used to derive the user’s principal for this issuer. If you do not use alternative derivation origins, return the canister’s default URL:

https://<issuer-canister-id>.icp0.io

If you use alternative origins, return the same value as your derivationOrigin login parameter. The returned value is verified via .well-known/ii-alternative-origins.

Validates the credential request and prepares the credential. This endpoint must:

  • Validate the request from II (check that the caller is II, the credential type is supported, and the user meets the credential requirements).
  • Update certified_data with a new root hash that includes the pending signature on the credential.

The endpoint returns a prepared_context opaque value that is passed unchanged to get_credential. Use it to carry the unsigned VC and any state needed to complete signing.

Issues the signed credential. This endpoint:

  • Runs the same validation as prepare_credential.
  • Verifies that prepared_context is consistent with the earlier preparation step.
  • Returns the signed credential as a JWT.

The credential is signed using a canister signature: a signature produced by the canister’s key, not an ECDSA or Ed25519 key. This means the canister must update certified_data in prepare_credential before the signature becomes available in get_credential.

Return credentials using this convention so relying parties can verify them consistently.

Given a credential specification:

{
"credentialSpec": {
"credentialType": "VerifiedAdult",
"arguments": {
"minAge": 18
}
}
}

The issued JWT credentialSubject should contain:

{
"VerifiedAdult": {
"minAge": 18
}
}

The credentialType value is used as the key in credentialSubject, and the arguments become key-value entries under it.

A compliant issuer for age verification would implement prepare_credential to check whether the user has a verified date of birth on record, and get_credential to return a signed JWT attesting VerifiedAdult with minAge: 18.

For complete Rust implementations of all four API endpoints, see the vc-playground issuer example. This is the primary reference implementation. The four endpoints above require careful handling of canister signatures and certified data, and the reference implementation shows the complete pattern including error handling and Candid interface definitions.

A relying party requests credentials from issuers through Internet Identity. The relying party must:

  1. Open an II window and initiate the VC flow.
  2. Request the credential.
  3. Receive and verify the returned verifiable presentation.

The @dfinity/verifiable-credentials package handles the window messaging protocol for you. This is a dedicated VC package: it is separate from the @icp-sdk/* family used for general authentication.

import { requestVerifiablePresentation } from "@dfinity/verifiable-credentials/request-verifiable-presentation";
requestVerifiablePresentation({
onSuccess: async (verifiablePresentation) => {
// verifiablePresentation is a JWT string: validate it before trusting it
console.log("Received VP:", verifiablePresentation);
},
onError(err) {
console.error("VC flow failed:", err);
},
issuerData: {
origin: "https://employment-info.com",
canisterId: "rwlgt-iiaaa-aaaaa-aaaaa-cai",
},
credentialData: {
credentialSpec: {
credentialType: "VerifiedEmployee",
arguments: {
employerName: "XYZ Ltd.",
},
},
credentialSubject: userPrincipal, // the user's principal at the relying party
},
identityProvider: new URL("https://id.ai"),
derivationOrigin: undefined, // set if your RP uses alternative derivation origins
});

The SDK:

  • Opens a new II window.
  • Waits for the vc-flow-ready message.
  • Sends the request_credential JSON-RPC call.
  • Calls onSuccess with the VP JWT on success, or onError if the user cancels or an error occurs.

Note: onSuccess fires when the VP is received: it does not mean the credential is valid. You must verify the VP before acting on it.

If you prefer to implement the window message protocol yourself, the three steps are:

Step 1: Open the II window

Open a window to the identity provider’s /vc-flow path:

const iiWindow = window.open("https://id.ai/vc-flow");

Wait for the vc-flow-ready postMessage from II before sending a request.

Step 2: Send the credential request

Send a JSON-RPC request_credential message:

{
"id": 1,
"jsonrpc": "2.0",
"method": "request_credential",
"params": {
"issuer": {
"origin": "https://employment-info.com",
"canisterId": "rwlgt-iiaaa-aaaaa-aaaaa-cai"
},
"credentialSpec": {
"credentialType": "VerifiedEmployee",
"arguments": {
"employerName": "XYZ Ltd."
}
},
"credentialSubject": "2mdal-aedsb-hlpnv-qu3zl-ae6on-72bt5-fwha5-xzs74-5dkaz-dfywi-aqe"
}
}

Parameters:

FieldRequiredDescription
issuer.originYesThe origin URL of the issuer service
issuer.canisterIdNoThe issuer canister ID (optional, helps II locate the issuer)
credentialSpec.credentialTypeYesThe type of credential being requested
credentialSpec.argumentsNoCredential-specific arguments (e.g., minAge)
credentialSubjectYesThe user’s principal at the relying party
derivationOriginNoAlternative derivation origin for the RP’s principal

Step 3: Receive and handle the response

On success, II returns:

{
"id": 1,
"jsonrpc": "2.0",
"result": {
"verifiablePresentation": "eyJQ..."
}
}

On failure:

{
"id": 1,
"jsonrpc": "2.0",
"error": {
"version": "1",
"code": "UNKNOWN"
}
}

Errors intentionally provide no details about the failure reason (to protect user privacy). Handle both success and error cases, and treat the user closing the II window as an error.

The verifiablePresentation value is a JWT. Do not trust it without verification.

The VP is a JWT with no signature in the outer layer. Decoded, it contains:

{
"iss": "<relying-party-principal-or-ii>",
"vp": {
"@context": "https://www.w3.org/2018/credentials/v1",
"type": "VerifiablePresentation",
"verifiableCredential": [
"<id-alias-credential-jwt>",
"<issued-credential-jwt>"
]
}
}

The verifiableCredential array always contains exactly two JWTs in this order:

  1. id-alias credential: signed by Internet Identity. Proves that the relying party’s user principal maps to the id_alias.
  2. Issued credential: signed by the issuer. The subject is the id_alias.

id-alias credential decoded:

{
"iss": "https://identity.internetcomputer.org/",
"sub": "<relying-party-user-principal>",
"vc": {
"type": ["VerifiableCredential", "InternetIdentityIdAlias"],
"credentialSubject": {
"InternetIdentityIdAlias": {
"hasIdAlias": "<id-alias>",
"derivationOrigin": "<derivation-origin>"
}
}
}
}

Issued credential decoded:

{
"iss": "<issuer-origin>",
"sub": "<did-id-alias>",
"vc": {
"type": ["VerifiableCredential", "<credential-type>"],
"credentialSubject": {
"<credential-type>": {
"<argument-key>": "<argument-value>"
}
}
}
}

Both credentials are signed using canister signatures. To verify them:

  1. Decode the outer VP JWT.
  2. Extract the two inner JWTs from vp.verifiableCredential.
  3. Verify the id-alias credential signature against II’s canister signature.
  4. Verify the issued credential signature against the issuer’s canister signature.

See the vc_util library in the Internet Identity repository for a reference implementation of canister signature verification.

After verifying the signatures, check the following:

From the id-alias credential:

  • iss is https://identity.internetcomputer.org/ (the expected II canister).
  • sub contains the user’s principal at the relying party.
  • Credential type is InternetIdentityIdAlias.
  • derivationOrigin matches the derivation origin used when logging into the relying party.

From the issued credential:

  • vc.type includes the credential type you requested.
  • The first field in vc.credentialSubject matches vc.type[1] (the credential type).
  • The arguments in vc.credentialSubject.<credential-type> match what was requested.

Cross-credential check:

  • The sub of the issued credential matches vc.credentialSubject.InternetIdentityIdAlias.hasIdAlias from the id-alias credential. Note that this sub value uses the did: URI scheme (for example, did:ic:...): it is not a bare principal text. Compare the full DID string, not just the principal portion.

This chain confirms that the issuer attested the claim for the same id_alias that II linked to your user’s principal.

See the demo relying party implementation for a complete example.

A demo relying party is deployed on ICP for testing: https://l7rua-raaaa-aaaap-ahh6a-cai.icp0.io/

Use the II staging instance to avoid using real user credentials: https://fgte5-ciaaa-aaaad-aaatq-cai.ic0.app/

A demo issuer is deployed that will issue any requested credential. Explore the issuer canister on the NNS dashboard or browse its implementation in the vc-playground repository.

Run Internet Identity locally by setting ii: true in your icp.yaml:

networks:
- name: local
mode: managed
ii: true

The II frontend will be available at http://id.ai.localhost:8000. Point your identityProvider at this URL during local development.

The VC protocol provides the following privacy guarantees:

  • Unlinkability: The issuer learns the user’s id_alias, not their principal at the relying party. The relying party learns the id_alias, not the user’s principal at the issuer. Neither party can correlate the user’s identity across both services.
  • User consent: No credential is issued without the user explicitly approving the consent dialog shown by Internet Identity.
  • Opaque errors: Error responses from II do not reveal why a credential request failed, preventing information leakage about the user’s status at the issuer.