Article

Three things that will cost you a day with C2PA

Article 50 of the EU AI Act has applied since 2 August 2026, so a lot of people are about to implement Content Credentials for the first time. These are the three traps that cost me the most time — none of them are in the documentation, and two of them fail quietly enough that you will ship before you notice.

I have written elsewhere about what the Article 50 marking looks like and about certificates and the valid-versus-trusted distinction. This is the other half: the things that go wrong between "I understand the format" and "a verifier accepts my file". Everything below comes from a working chain — build, sign, read, verify — against c2patool.

The examples are Node, because that is where the C2PA bindings live even when your application is PHP. The failure modes are not language-specific.

1. sign() does not return the signed file

In @contentauth/c2pa-node, builder.sign(signer, source, dest) returns the C2PA manifest-store bytes — the JUMBF blob. Your signed asset is written to dest.

Treat the return value as your image and you write a file that is not an image at all. Reading it back fails with an invalid header: the bytes 6A 75 6D 62, which is ASCII for jumb. Once you know that, the error is obvious. Before you know it, you are debugging your reader.

What makes this easy to walk into is that the library's own example asserts only that the returned buffer is non-empty. It is non-empty. It is also not your image.

2. Any re-encode destroys the credential

The signature covers the file's bytes through a hash binding. Re-encode, resize, optimise, rewrite metadata, strip EXIF — the manifest no longer matches and the credential is invalid.

This is the one that actually breaks working integrations, because it fails silently in the worst way: the image still displays perfectly. Nothing errors. You only find out when someone verifies the file and gets nothing.

And a typical web stack is full of offenders. Image optimisers. Thumbnail generation. A CDN that recompresses on delivery — Cloudflare Polish, imgix, Fastly image optimisation. Any of them will quietly undo your signing.

If you take one thing from this article: audit your image pipeline before you write any signing code. Find every place bytes get rewritten between generation and delivery, and decide which of them must stop. That is a bigger conversation than the signing itself, and discovering it afterwards means unpicking work you have already done.

There is a subtler consequence. Some upstream providers already attach Content Credentials to their output. If your application downloads such an image and runs it through a thumbnailer, you have destroyed a credential somebody else created — and you may not have known it was there. You then have two options: preserve the original bytes untouched, or re-sign under your own identity. The second is a different and arguably stronger statement. We stand behind this file, not just our supplier.

3. Trust verification that verifies nothing, silently

I have written before about the trap where trust settings take PEM contents rather than file paths. Here is the one underneath it, which is worse.

Enable verify_trust but supply no trust material — no anchors, no allowed list — and nothing complains. No exception, no warning, no startup error. Every read comes back valid with signingCredential.untrusted.

Which is byte-for-byte what a correctly configured verifier reports for a genuinely untrusted certificate.

That is the whole problem. From the outside, "trust verification is on and this certificate failed it" and "trust verification is not actually running" produce identical output. An operator who believes verification is enabled has no way to discover that it is not. Note the asymmetry: malformed trust material does throw. It is the absent case that passes quietly — so validating that your settings parse proves nothing at all.

The only defence I found is to refuse to start. If a settings document could never verify anything — verify_trust missing, or no non-empty anchors or allowed list — the service should exit rather than run. A configuration error at startup is loud. A verifier that silently verifies nothing is not.

The shape these share

Two of the three fail without telling you. The signed file that is not an image is the polite one — it errors immediately, and you fix it the same afternoon. The other two let you ship: a credential quietly destroyed by your CDN, a trust check that was never actually running.

That shaped how I built the library this comes from. Anything that can fail quietly gets a loud failure instead — the service refuses to start on unusable trust settings, and reading a credential distinguishes what a file claims from what actually verified:

$report->isAiGenerated();          // what the manifest CLAIMS
$report->isVerifiedAiGenerated();  // marked AND the signature checked out

The first answers just as confidently for a manifest that failed every validation check. Before you act on a credential — showing a badge, allowing an upload, filing a compliance record — gate on the verdict, not the claim.

All three are pinned by tests in provemark/content-credentials, an open-source PHP library for building, signing, reading and verifying C2PA manifests — framework-agnostic core, Laravel integration, and a signing service that keeps the private key out of your web application. Its NOTES.md is the full log these came from.