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:

  1. Is the signature valid? Was the content signed, and is it intact — nothing tampered since signing?
  2. 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_stateMeaning
ValidSignature and content-integrity checks passed, but the certificate is not on a trust list (or trust checking was disabled).
TrustedAll of the above and the certificate chains to a trusted CA.
InvalidSomething 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).

Takeaway: use the aggregate 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:

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/:

Never commit a private key. Even a test key stays out of version control. Fetch it at setup time instead:
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:

  1. verify.verify_trust = true, and
  2. the trust material: trust anchors plus the allowed signing EKUs (a trust_config of OID dot-notation), or an allowed_list of specific signing certificates.

Three traps I hit, so you don't have to:

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.

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.

The trust-list and CA landscape moves quickly — SSL.com is first, not last. Check the current C2PA trust list when you're ready to buy.

A checklist to keep straight

  1. Test: c2pa-rs es256 sample certs; algorithm matches the key; private key never committed.
  2. Valid vs trusted: expect Valid + signingCredential.untrusted with test certs; judge integrity by validation_state, not a single code.
  3. Trusted locally: verify_trust=true and trust anchors + EKU config (or an allowed-list); mind the contents-not-paths trap; never init trust.
  4. Architecture: keep the key off the web server — delegate to an isolated signing service.
  5. Production: a CA on the C2PA trust list (conformance program); add TSA timestamping for longevity.
I put all of this into a small open-source PHP library — provemark/content-credentials — that builds, signs, reads and verifies C2PA manifests (framework-agnostic core plus a Laravel integration), keeping the signing key isolated behind a service. It ships the exact trust-verification tooling the examples above come from.