Skip to content

Trust Model

A verifier has to answer one question: did this credential really come from an issuer I am willing to believe?

It answers it from the credential itself. Credentials are signed, and the signature carries the issuer's X.509 certificate chain in the JOSE x5c header. The verifier reads the chain out of the credential, so it needs no network lookup and no contact with the issuer at verification time.

How an issuer is resolved

When the verifier receives a presentation, it parses the certificate chain in the credential's x5c header and checks the issuer's signature against the public key of the leaf certificate.

Without an x5c header, the verifier cannot resolve the issuer and verification fails.

What gets validated

A signature that checks out only tells you the credential was signed by whoever holds that certificate's private key. It does not tell you that you recognize them. Establishing that second thing is what trust anchors are for.

Every credential gets PKIX path validation: the verifier builds a path from the presented leaf certificate up to one of the roots in issuerTrustAnchors and rejects the credential if no valid path exists. This applies to SD-JWT and mdoc alike.

Anchors are deployment-wide: they decide which issuers this verifier accepts at all. A single verification query can narrow that further with a trusted_authorities entry, accepting only credentials issued under a named certificate authority. That is a filter on top of the anchors, not a substitute for them.

issuerTrustAnchors is a required setting, so this step always runs. If the directory holds no anchors, no path can be built and every credential is rejected.

One limit applies: chains are limited to 10 certificates, and a longer x5c chain is rejected before validation begins.

Validity periods are checked, so an expired certificate anywhere in the path fails it.

The verifier's own identity

Trust runs in both directions. Before a wallet hands over a presentation, it may want to know which verifier is asking.

The verifier signs its authorization requests with its own key and certificate, and derives its OID4VP client_id from that certificate:

client_id = "x509_hash:" + base64url( sha256( DER-encoded leaf certificate ) )

Deriving the identifier this way is what makes the check possible at all: because the client_id is a hash of the certificate, a verifier cannot claim an identity it does not hold the key for. A wallet that wants to limit which verifiers it will answer therefore can — it validates the certificate chain in the request's x5c header against its own trusted roots, verifies the request signature, and confirms that the hash in client_id matches the certificate it just validated.

Whether a wallet does so is its own decision. OID4VP leaves it to the wallet, so this is a capability wallets may use rather than a step a verifier can require, and wallets differ. The Partisia ID Wallet accepts any verifier; a wallet built for a closed ecosystem may instead refuse every verifier outside it.

So a verifier presenting a self-signed certificate is accepted by wallets that do not check, and by wallets configured to accept that certificate — and refused by the rest. A self-signed pair is what the quickstart generates, and it is fine for local testing. For a deployment facing wallets you do not operate, your certificate needs to chain to a root they already trust. See Going to production.

Configuring your own keys and certificates

Both services take the same signing block: a private key file and an ordered, leaf-first list of certificate files, which become the x5c header they emit.

"signing": {
  "keyFile": "conf/signing-key/verifier-localhost.key",
  "certificateFiles": ["conf/signing-key/verifier-localhost.pem"]
}

See Signing certificates for generating these and for what changes when you move off self-signed certificates.

What's Next