Claude now attaches signed C2PA provenance metadata to the files it generates. So do OpenAI’s image models, and so does Gemini. Which means a real provenance signal is arriving at your upload endpoint for the first time, and there’s a good chance your pipeline is deleting it before anyone sees it.
Not maliciously. By default. sharp().resize() produces a clean file with no metadata unless you ask otherwise. So does ImageMagick. So does Pillow. So do most image CDNs. The manifest goes in, a smaller JPEG comes out, and nothing in your logs mentions it.
This is a testable failure, and the test is not complicated. Here’s how metadata dies in a normal pipeline, how to prove it’s happening, and how to wire a round-trip check into CI so it can’t come back. Apidog handles the orchestration; c2patool handles the byte-level verification.
What actually gets destroyed
A C2PA manifest is a cryptographically signed block embedded in the file container. It records who signed the asset and what was claimed about it, and because it’s signed, altering the bytes without re-signing breaks the signature in a way any reader can detect.
Container-level is the operative phrase. Rewrite the container and the manifest is gone.
| Operation | Manifest survives by default? |
|---|---|
| Byte-for-byte copy or move | Yes |
sharp().resize().toBuffer() |
No |
ImageMagick convert / magick |
No |
Pillow Image.save() |
No |
| PNG to WebP, JPEG to AVIF | No |
| Image CDN auto-optimization | Usually no |
| Screenshot | No |
| Re-save from an image editor | No |
| S3 upload with no transformation | Yes |
Everything in the “no” column is something a normal web app does to every image it accepts. Thumbnails, responsive variants, format negotiation, EXIF scrubbing for privacy. Each one is reasonable in isolation, and each one silently ends the provenance chain.
Worth noting: the privacy-motivated -strip habit is often deliberate, because EXIF carries GPS coordinates and camera serials. Stripping all metadata to remove location data also removes the provenance manifest. Those two goals now conflict, and resolving it means being selective rather than nuking the whole block.
Prove it in two minutes
Before building anything, confirm you have the problem. You need one file with a valid manifest. Any image Claude generates works, or grab a signed sample from the Content Authenticity Initiative.
Install the reference CLI:
cargo install c2patool
Check the fixture is actually signed:
c2patool fixtures/signed-sample.png
You should get a JSON report naming the claim generator and the signature status. Now push it through your own stack and check the other end:
# Upload through your real endpoint
curl -sS -X POST https://api.example.com/v1/assets \
-H "Authorization: Bearer $API_TOKEN" \
-F "file=@fixtures/signed-sample.png" \
-o /tmp/upload.json
# Fetch it back through the URL your frontend would use
ASSET_URL=$(jq -r '.url' /tmp/upload.json)
curl -sS "$ASSET_URL" -o /tmp/roundtrip.png
# Did the manifest survive?
c2patool /tmp/roundtrip.png
Three possible outcomes, and they mean different things:
- A valid report. The manifest survived. Good.
- No manifest found. Something in your pipeline stripped it. This is the common case.
- A validation error. A manifest is present but its signature no longer matches the bytes. Something modified the file and left the old manifest attached, which is worse than stripping it, because it looks like tampering to any downstream verifier.
That third outcome is the one to hunt for. It usually means a transformation library preserved the metadata block while rewriting the pixels.
Find the step that does it
If the round trip fails, bisect the pipeline. Check the manifest immediately after each stage rather than guessing.
Typical suspects in order of likelihood:
1. The resize or thumbnail step. Most likely culprit. In sharp, metadata is dropped unless you keep it explicitly:
// Drops the C2PA manifest
await sharp(input).resize(1200).toFile(output);
// Preserves the metadata block
await sharp(input).resize(1200).keepMetadata().toFile(output);
Preserving the block is necessary but not sufficient. The pixels changed, so the original signature no longer validates against the new bytes. To keep a working provenance chain you re-sign the output and record the transformation as an action assertion, typically c2pa.resized. The c2pa libraries for Rust, Python, JavaScript, and C all support this.
2. Format conversion. Serving AVIF or WebP means a new container. Same rule: preserve and re-sign, or accept the chain ends there and say so.
3. The CDN. Many image CDNs rewrite on delivery. Some now preserve and re-sign Content Credentials natively; most historically stripped them. Test through the delivery URL your users actually hit, not through origin, or you’ll get a green result that means nothing.
4. Upload normalization. Services that re-encode on ingest to standardize formats are easy to forget, because the code lives in an infrastructure repo nobody reads.
Make it a permanent test
A one-off curl proves the state today. It doesn’t stop someone adding a resize step next sprint. The check needs to live in CI.
Split it into two layers, because two different tools are good at two different things.
Layer one: the round trip, in Apidog
The orchestration is a normal chained API test: upload a fixture, capture the returned URL, fetch the asset back through the real delivery path, assert on what comes back.
In Apidog this is a test scenario with two steps.
Step 1: POST /v1/assets
- Body:
multipart/form-datawith your signed fixture attached. The mechanics are the same as in testing file upload APIs. - Assertions: status is
201, and the response matches your schema. - Post-response script to hand the URL to the next step:
const body = pm.response.json();
pm.environment.set("ASSET_URL", body.url);
pm.test("upload returns a delivery URL", function () {
pm.expect(body.url).to.be.a("string").and.to.include("https://");
});
Step 2: GET {{ASSET_URL}}
- Assertions: status is
200,Content-Typeis the format you expect, and the body size is close to what you uploaded. A dramatic size drop is a strong hint the file was re-encoded.
const uploadedBytes = Number(pm.environment.get("FIXTURE_BYTES"));
const returnedBytes = pm.response.responseSize;
pm.test("asset was not silently re-encoded", function () {
pm.expect(returnedBytes).to.be.above(uploadedBytes * 0.9);
});
Size is a heuristic, not proof. It catches the loud failures cheaply and it runs in the same suite as everything else. Standard assertion patterns are covered in API assertions.
Layer two: the byte-level check, in CI
Verifying a signature means parsing the container, which is c2patool’s job, not an HTTP client’s. Run it as a pipeline step against the file the round trip fetched:
# .github/workflows/provenance.yml
name: provenance
on: [pull_request]
jobs:
c2pa-round-trip:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install c2patool
run: cargo install c2patool
- name: Install Apidog CLI
run: npm install -g apidog-cli
- name: Run the round-trip scenario
run: |
apidog run --access-token "$APIDOG_ACCESS_TOKEN" \
-t "$SCENARIO_ID" -e "$ENV_ID" -r cli,html --out-dir ./apidog-reports
env:
APIDOG_ACCESS_TOKEN: ${{ secrets.APIDOG_ACCESS_TOKEN }}
SCENARIO_ID: ${{ vars.PROVENANCE_SCENARIO_ID }}
ENV_ID: ${{ vars.APIDOG_ENV_ID }}
- name: Verify the manifest survived
run: |
set -euo pipefail
curl -sS "$ASSET_URL" -o /tmp/roundtrip.png
c2patool /tmp/roundtrip.png > /tmp/report.json
jq -e '.validation_status == null or (.validation_status | length) == 0' /tmp/report.json
set -euo pipefail matters. Without it, c2patool failing on a stripped file produces a warning and a green build, which is the exact failure you were trying to prevent. If you’re new to running Apidog scenarios in a pipeline, automating API tests in GitHub Actions covers the setup.
Layer three, optional: a verification endpoint
If provenance is a product feature rather than an internal check, the cleanest design is a small endpoint in your own service that runs the c2pa library and returns a structured result. Then the whole thing is testable as ordinary JSON, and your frontend gets a real answer instead of a guess.
{
"asset_id": "img_9f2c41",
"provenance": {
"status": "verified",
"standard": "c2pa",
"signer": "Anthropic",
"signature_valid": true,
"checked_at": "2026-08-11T09:14:22Z",
"tool": "c2patool/0.9"
}
}
Keep three states, not two. verified, absent, and invalid mean genuinely different things, and collapsing absent and invalid into one boolean throws away the most interesting signal you have. Add unchecked if your verifier can be unavailable, so an outage doesn’t masquerade as a clean result.
Document the shape in your OpenAPI definition and validate against it, so the fields can’t disappear in a refactor. How to validate OpenAPI specs covers that side.
The four fixtures worth keeping
A provenance suite needs deliberately broken inputs, not just a happy path.
- Valid signed file. Expect
verified. Catches over-eager stripping. - Stripped file. Same image, manifest removed with
exiftool -all=. Expectabsent, not an error and definitely notverified. - Tampered file. Signed file with a byte altered after signing. Expect
invalid. This is the one that proves you’re checking the signature rather than just checking a block exists. - Unsupported format. Something with no manifest support at all. Expect a clean
absentrather than a 500.
Commit all four to the repo next to the test scenario. They’re small, they never change, and they’re the difference between a test that passes and a test that means something.
Why bother
Three reasons, in ascending order of how much they’ll cost you.
Your product claim. If your UI shows a provenance badge, and your pipeline strips manifests, the badge is wrong for every asset that went through a resize. That’s a trust problem you’ll find out about from a user.
Your compliance story. If you’re relying on C2PA for anything Article 50 related, a stripped manifest is a control that isn’t working. The provider and deployer split in EU AI Act Article 50 for API developers explains which duties actually sit with you.
The signal itself. Provenance only works if the chain holds end to end. Every pipeline that quietly drops manifests makes the whole ecosystem less useful, including for you when you’re the one trying to verify something.
Download Apidog to build the round-trip scenario against your own endpoints, then wire the c2patool step in behind it.
FAQ
Does resizing an image remove C2PA metadata? Yes, by default in every common library. Preserving the metadata block requires an explicit flag, and keeping a valid signature requires re-signing the transformed output.
How do I check if a file has C2PA metadata? Run c2patool <file> from the command line, or drop the file into the Content Credentials verification page.
Can I keep C2PA metadata through a resize? Yes, but not by preserving alone. Preserve the block, then re-sign the output with an action assertion such as c2pa.resized using one of the c2pa libraries. Otherwise the old signature no longer matches the new bytes.
Do CDNs strip Content Credentials? Many do when they auto-optimize. Some now preserve and re-sign natively. Test through the delivery URL your users hit, not through origin.
What’s the difference between a stripped manifest and an invalid one? Stripped means no manifest was found, which tells you nothing about the file’s origin. Invalid means a manifest exists but its signature doesn’t match the bytes, which means the file changed after signing. Keep them as separate states.
Can Apidog verify a C2PA signature directly? It orchestrates the round trip and asserts on HTTP responses, including a verification endpoint’s JSON. The signature parsing itself is c2patool’s job, run as a CI step or inside your own service. Use both together.
Should I strip EXIF for privacy but keep C2PA? That’s the right goal and it needs a selective approach. A blanket -strip removes both. Remove the EXIF blocks you care about specifically and leave the C2PA manifest intact.
The takeaway
Provenance metadata arrives at your API intact and usually leaves in pieces, and nothing in your monitoring will tell you. The fix is a fixture, a round trip through the real delivery path, and a c2patool check that fails the build.
Twenty minutes of setup, and it turns a claim you’re making in your UI into a guarantee your pipeline actually enforces.



