Article
Valid ≠ trusted: a practical guide to C2PA signing certificates
Building a manifest is the easy part of C2PA. Signing it — and being trusted — is where everyone gets stuck. This is what I learned about certificates while getting the whole chain working in PHP, from the c2pa-rs test certificates through to a production trust list.
Most "what is C2PA / Content Credentials" explainers stop before the hard part. This one
starts there. Every claim below comes from a working implementation: build → sign → read →
verify, with a real signature that c2patool accepts. If you're implementing
Content Credentials — especially outside the Rust/JavaScript mainstream — the certificate and
trust story is the part that will cost you a day. Here it is up front.
The one distinction that explains everything
A C2PA verifier answers two separate questions, and conflating them is the single most common mistake:
- Is the signature valid? Was the content signed, and is it intact — nothing tampered since signing?
- Is the certificate trusted? Was it issued by a certificate authority on a trust list the verifier recognises?
These are independent. A signature can be perfectly valid while the certificate is
untrusted — which is exactly what happens with test certificates. c2pa-rs makes this
explicit with a top-level validation_state:
validation_state | Meaning |
|---|---|
Valid | Signature and content-integrity checks passed, but the certificate is not on a trust list (or trust checking was disabled). |
Trusted | All of the above and the certificate chains to a trusted CA. |
Invalid | Something failed — e.g. the asset was modified after signing. |
"Valid but untrusted" is not a bug. It's the normal state during development, and it's the state you'll ship in until you have a production certificate.
A subtlety that will bite you
You might reach for a single success code to decide "is it valid?" — for example
claimSignature.validated. Don't. Here's a result that surprised me: I signed a
PNG, flipped a single byte in the middle of the file, and re-read it.
validation_state: "Invalid"
success: [ ..., "claimSignature.validated", "assertion.hashedURI.match", ... ]
failure: [ "signingCredential.untrusted", "assertion.hashedURI.mismatch" ]
The tampered file is Invalid — correctly — but claimSignature.validated
is still present in the success list. That's because the claim signature covers
the claim, and the claim is intact; the asset is bound to the claim through separate hash
assertions, and it's those that fail (assertion.hashedURI.mismatch).
validation_state
(Valid/Trusted), not one hand-picked code, to decide whether a
signature holds over this asset.
Anatomy of a signing setup
To produce a Content Credential you need three things that all have to agree:
- a certificate chain — leaf → intermediate → root;
- the corresponding private key;
- a signing algorithm that matches the key type — ES256 for an EC key, PS256 for RSA. Mismatch the two and signing fails immediately.
There's a fourth, quieter requirement: the certificate's Extended Key Usage (EKU) must be one C2PA permits for claim signing. More on that below — it's a classic silent failure.
Getting started without a real certificate
You do not need a production certificate to build and test the whole chain. c2pa-rs ships
test material in cli/sample/:
es256_certs.pem— a two-certificate chain (leaf + intermediate);es256_private.key— the matching ES256 key (soalg = es256);trust_anchors.pem,allowed_list.pem,store.cfg— the pieces you need to make trust pass locally.
curl -sfSL https://raw.githubusercontent.com/contentauth/c2pa-rs/main/cli/sample/es256_private.key \
-o certs/es256_private.key
Public test CA certs are fine to keep; private keys never are.
Making trust actually pass (and the traps)
With the test certs, a default verification reports the signature as valid but the
certificate as signingCredential.untrusted. To get to Trusted locally
you need two things together — people usually set one and wonder why nothing
changes:
verify.verify_trust = true, and- the trust material: trust anchors plus the allowed signing EKUs (a
trust_configof OID dot-notation), or anallowed_listof specific signing certificates.
Three traps I hit, so you don't have to:
- The settings fields take file contents, not paths. In a
c2pasettings file,trust.trust_anchorsandtrust.trust_configexpect the PEM / EKU text as strings — passing a filename silently gives you nothing. - EKU matters. The test leaf's EKU is E-mail Protection
(
1.3.6.1.5.5.7.3.4). The samplestore.cfglists exactly the OIDs C2PA allows for signing, so the chain passes; get the EKU config wrong and a perfectly valid cert is rejected. - Never run
c2patool init trustin a test project. It fetches the production trust list, which correctly rejects your test certificate — and then you're debugging the wrong thing.
Wire those up and an honest end-to-end verify looks like this:
Signed by : C2PA Test Signing Cert / CN=C2PA Signer [Es256]
Signature valid: PASS (claimSignature.validated)
Cert trusted : PASS (signingCredential.trusted)
AI Art.50 mark : PASS (digitalSourceType=trainedAlgorithmicMedia)
Where the private key lives is an architecture decision
Certificates force a design choice most tutorials skip: where does the private key live at signing time? There are two models.
- In-process — a native library/extension signs inside your application process. Simple, but the private key sits on your web server.
- Isolated signing service — signing is delegated to a separate service that holds the key, off the application host. This is the pattern the CAI's own tooling uses.
For anything handling real signing keys, isolation is the defensible choice: the signing key never touches your web application. A compromised web process can request a signature; it can't walk away with the key. That's a sentence any security-minded reviewer understands, and it's the model I built around.
Going to production: a real, trusted certificate
Test certs get you a valid signature, never a trusted one. To be Trusted in the
wild, your certificate must come from a CA on the C2PA trust list, which runs
through the C2PA
conformance program: an expression of interest, then a conformance and security evaluation
before certificates are issued
(CAI: getting a
signing certificate).
As of 2026 this stopped being theoretical: SSL.com became the first publicly-trusted CA to issue production C2PA-conformant certificates, and its free tier now includes a Level 1 Claim Signing Certificate (valid one year) plus 10,000 trusted timestamps per year. "Free" is not a shortcut around the gate, though: the free tier still requires a valid C2PA conformance record ID at application — you pass conformance first, then the certificate itself is free.
Which brings up timestamping. A trusted timestamp (RFC 3161, from a TSA) records when a signature was made, so a credential stays verifiable even after the signing certificate expires. For anything you want to outlive a one-year certificate, wire in a TSA URL when you sign.
A checklist to keep straight
- Test: c2pa-rs es256 sample certs; algorithm matches the key; private key never committed.
- Valid vs trusted: expect
Valid+signingCredential.untrustedwith test certs; judge integrity byvalidation_state, not a single code. - Trusted locally:
verify_trust=trueand trust anchors + EKU config (or an allowed-list); mind the contents-not-paths trap; neverinit trust. - Architecture: keep the key off the web server — delegate to an isolated signing service.
- Production: a CA on the C2PA trust list (conformance program); add TSA timestamping for longevity.