Short answer. A C2PA manifest is a signed statement about a media file: a claim that lists assertions (what was done, such as "created by a camera" or "edited with generative AI", plus a hash of the file's bytes), signed with COSE by a key whose X.509 certificate sits in the signature. Validators report a manifest as Well-Formed, Valid (the signature and the hashes check out) or Trusted (the signer's certificate chains to a trust anchor, by default the C2PA Trust List). An RFC 3161 timestamp keeps it valid after the certificate expires. What it does not do: prove that the picture shows the truth, or survive tools that re-encode the file. Treat a valid credential as "this signer vouches for these bytes", and its absence as no information at all.
What is in a manifest
The C2PA specification (version 2.4, April 2026) builds a manifest from a few parts:
- Assertions, each a labelled CBOR or JSON box.
c2pa.actions.v2lists what happened to the asset (since 2.1, "Either ac2pa.createdor ac2pa.openedis now mandatory"), with adigitalSourceTypefrom the IPTC vocabulary that distinguishes a camera capture from generative AI.c2pa.hash.datais the hard binding: a hash over the asset's bytes, excluding the range where the manifest itself is stored. A thumbnail, ingredients (the assets this one was made from) and metadata may follow. - The claim: "A digitally signed and tamper-evident data structure that references a set of assertions", by hashed URI, so changing any assertion breaks the claim.
- The claim signature: a
COSE_Sign1over the claim, with the signer's certificate chain in the protected header. "Exactly one instance of an identity credential shall appear", and since version 2.0 that credential is an X.509 certificate. - An optional timestamp from an RFC 3161 authority, over the signature.
All of it is serialized as JUMBF boxes inside the file (an APP11 segment in JPEG, a box in MP4 or HEIF) or stored remotely and referenced by URL. When an asset is edited, the editor adds a new manifest and records the previous one as an ingredient, so the store becomes a chain of manifests.
The allowed signature algorithms are ES256, ES384, ES512, PS256, PS384, PS512 and Ed25519 (§13.2.1); keys must match the algorithm, and RSA keys must have at least 2048 bits. For live video the specification recommends deterministic algorithms, so that identical segments stay cacheable.
Signing a JPEG
The lab creates a test CA with pyca/cryptography 49.0.0 and a P-256 signer certificate with the EKUs a C2PA validator looks for, c2pa-kp-claimSigning (1.3.6.1.4.1.62558.2.1) and, for older validators, id-kp-emailProtection. It then signs a generated 640×360 JPEG with a DigiCert timestamp:
manifest = {
"claim_generator_info": [{"name": "ecdsa.com C2PA lab", "version": "1.0"}],
"title": "sunrise.jpg",
"assertions": [
{"label": "c2pa.actions.v2", "data": {"actions": [
{"action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"}
]}}
],
}
signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo(alg=b"es256", sign_cert=chain_pem, private_key=key_pem, ta_url=b"http://timestamp.digicert.com"))
builder = c2pa.Builder.from_json(manifest)
signed = io.BytesIO()
builder.sign(signer, "image/jpeg", io.BytesIO(original), signed)
The certificate profile matters: the signer certificate carries digitalSignature in Key Usage, CA:FALSE, and the claim-signing EKU; the root is a CA with keyCertSign. A certificate without a suitable EKU, or a self-signed leaf, fails validation even if the signature is mathematically correct. X.509 certificate fields explains those extensions.
Validation: Valid, Trusted, Invalid
The same script reads the file back four times: without any trust configuration, with the test root as a trust anchor, with one byte of image data changed, and after decoding and re-encoding the image with Pillow 12.0.0 as an upload pipeline would:
trusted = c2pa.Context.from_dict({"trust": {"trust_anchors": pem(root).decode()}, "verify": {"verify_trust": True}})
read("signed, test root as trust anchor", signed, trusted)
# Change one byte of the compressed image data (after the JPEG Start of Scan marker).
sos = signed.rfind(b"\xff\xda")
tampered = bytearray(signed)
tampered[sos + 200] ^= 0x01
read("one byte of image data changed", bytes(tampered), trusted)
# What many upload pipelines do: decode and re-encode, dropping metadata.
Image.open(io.BytesIO(signed)).save(out := io.BytesIO(), "JPEG", quality=85)
read("re-encoded by an image pipeline", out.getvalue(), trusted)
c2pa-python 0.38.1, 0.91.3
JPEG: 13376 bytes unsigned, 52438 bytes with the manifest (+39062)
--- signed, no trust anchors configured: validation_state = Valid
status codes: timeStamp.validated, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, assertion.dataHash.match, signingCredential.ocsp.skipped, timeStamp.untrusted, signingCredential.untrusted
signer: Example Newsroom / Example Newsroom Photo Desk, alg Es256, time 2026-10-22T06:43:11+00:00
assertions: ['c2pa.actions.v2']
assertion store: ['c2pa.actions.v2', 'c2pa.hash.data', 'c2pa.thumbnail.claim']
certificate chain in the signature: 981 bytes DER
--- signed, test root as trust anchor: validation_state = Trusted
status codes: timeStamp.validated, signingCredential.trusted, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, assertion.dataHash.match, signingCredential.ocsp.skipped, timeStamp.untrusted
--- one byte of image data changed: validation_state = Invalid
status codes: timeStamp.validated, signingCredential.trusted, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, signingCredential.ocsp.skipped, timeStamp.untrusted, assertion.dataHash.mismatch
--- re-encoded by an image pipeline: no C2PA manifest found
How to read it:
- Valid but untrusted. Every cryptographic check passed (
claimSignature.validated, threeassertion.hashedURI.matchfor the three assertions,assertion.dataHash.matchfor the image bytes), butsigningCredential.untrusted: anyone can create a CA and sign. The specification defines this state precisely: a manifest is Valid when it is well-formed, unmodified, its signature validates "inside validity" and the credential is not revoked; it is Trusted only when, in addition, the signing credential "receives the success code ofsigningCredential.trusted" (§14.3). - Trusted once the validator knows the root. In production that root comes from the C2PA Trust List, "a C2PA-managed list of X.509 certificate trust anchors that issue certificates to hardware & software signers", which validators shall include for the
c2pa-kp-claimSigningEKU; users may add their own anchors. - Invalid after one changed byte: the signature is still fine, the hard binding is not (
assertion.dataHash.mismatch). A crop, a filter or a re-compression has the same effect, which is why editors must write a new manifest rather than keep the old one. - Gone after re-encoding. The manifest lives in metadata; a pipeline that decodes pixels and writes a new file drops it, like EXIF. Nothing in the output says a credential was ever there.
The manifest added 39 KB to a 13 KB image: besides the claim and our 981-byte certificate chain, it carries a thumbnail that c2pa-rs generated and the timestamp token with DigiCert's certificates. timeStamp.untrusted appears because the lab did not configure the C2PA TSA Trust List: the timestamp is cryptographically valid, its authority simply was not in the validator's list.
Timestamps and certificate lifetime
The specification is direct: "manifests without time-stamps cease to be valid when the signing credential expires or becomes revoked", and when a timestamp is present and trusted, validators "shall use the attested time, and not the current time" to check the certificates (§10.3.2.5.1, §15.8.2). This is the same construction as in Authenticode: the certificate only has to be valid at the moment of signing, and the timestamp authority proves when that was. Devices that sign offline need another solution; Google's Pixel camera, for example, uses "on-device trusted timestamps" so that photos "can be trusted after the certificate expires, even if they were captured when your device was offline" (Google Security Blog).
Where trust comes from
The signer's identity is the certificate subject, so the value of a credential depends on who issued that certificate and what they checked. C2PA runs a conformance program and the trust lists; validators configure them by EKU and may add anchors with notBefore and notAfter dates. Hardware matters too: Google reports that the Pixel 10 Camera app "achieved Assurance Level 2, the highest security rating currently defined by the C2PA Conformance Program", with claim signing keys "generated and stored using Android StrongBox in the Titan M2 security chip" and certificates issued only after Android Key Attestation proves a genuine device and app. Keys in secure hardware are covered in hardware-backed keys.
Only the signed assertions are the signer's statement. The specification gives an example: "the signer is not responsible for the accuracy of a GPS location appearing in EXIF metadata, even if that metadata is covered by the hard binding" (§9.2.6); only metadata placed in a c2pa.metadata assertion is attributed to the signer.
What Content Credentials do not do
- They do not prove that an image is true. A camera credential says this device produced these bytes; a staged scene or a photo of a screen is still a camera capture. A generative-AI credential is a statement by the tool that chose to add it.
- They are easy to remove. Our re-encode dropped the manifest without an error, and people often remove metadata on purpose, for privacy or size. The specification's answer is soft binding: a fingerprint or invisible watermark that can find the manifest again in a repository. A "Durable Content Credential" is one "for which there exists one or more soft bindings that enable its discovery in a manifest repository" (§2.4.1). Whether a given platform stores and looks up manifests that way is up to the platform.
- Absence means nothing. Most images on the web have no credentials; a missing manifest is not evidence of manipulation.
- They can leak more than intended. Ingredient chains, device names and locations travel with the file. Creators can redact assertions when an asset becomes an ingredient (§6.8), and tools should let people choose what is included.
If you build with C2PA
- Preserve or re-sign in your pipeline. If your service resizes or compresses uploads, either keep the original with its credential, or create a new manifest with the original as an ingredient and an honest
c2pa.editedorc2pa.resizedaction, signed with your own certificate. - Show validation states truthfully. Distinguish Trusted from merely Valid, show the signer's name from the certificate, and show nothing special when there is no manifest.
- Validate with the C2PA trust lists and current software. Status codes such as
signingCredential.untrustedortimeStamp.untrustedusually mean configuration, not forgery. - Get the certificate right:
c2pa-kp-claimSigningEKU, key in an HSM or secure hardware, and timestamps on every signature. - Do not make decisions on absence. A missing or stripped credential is the normal case.
Sources
- C2PA Technical Specification 2.4: §2.3.15 and §2.3.16 (trust lists), §2.4.1 (durable credentials), §6.8 (redaction), §9.2.6 (non-C2PA metadata), §10.3.2.5.1 and §15.8.2 (timestamps), §13.2.1 (algorithms), §14.3 (validation states), §14.4 (trust lists)
- C2PA 2.3 announcement
- Google Security Blog: Pixel and Android C2PA Content Credentials (10 September 2025)
- RFC 9052: CBOR Object Signing and Encryption (COSE), RFC 3161
- Tools: c2pa-python 0.38.1 on c2pa-rs 0.91.3, pyca/cryptography 49.0.0, Pillow 12.0.0