For developers
How it works, under the hood
Three small services, one HSM and a registry. This page follows a picture through them: what happens to it, what gets written into it, and how it is found again. Everything here is in the open-source repository under Apache-2.0, with a decision record for each choice.
1
The services
Each piece is written in the language with the best library for its job: the C2PA reference implementation is Rust, TrustMark and PDQ are Python, and the API is .NET. They talk gRPC over mutual TLS 1.3, and only the API is reachable from outside.
Your app
Any language
- REST, the .NET or Python SDK, or proxy mode for fal and Replicate
Anyone checking
Public verify page
- No key; 30 a minute per address
API
.NET 10, ASP.NET Core
- Keys, quotas, the sign pipeline
- Verify and records
- Rules engine and packs
Registry
Postgres (Supabase, EU)
- A record per output: hashes, marks, report
- Never the picture
Signer
Rust, c2pa-rs (pinned)
- Builds, signs and validates manifests
- CAWG identity assertions
Cloud KMS
Google, HSM, Frankfurt
- Signing keys; never exported
Watermark worker
Python, TrustMark and PDQ
- Embeds and reads the watermark
- Computes fingerprints
2
Signing, step by step
One call to POST /v1/sign with a JPEG, PNG or WebP. The same pipeline serves proxy mode.
- 1API
Authenticate and count
The API key (
su_live_…, 256 random bits, stored only as a SHA-256 hash) names the company and must carry thesignscope. The month’s allowance is checked; requests are limited to 300 a minute per key. - 2Worker
Watermark
A fresh random 61-bit payload is embedded with Adobe’s TrustMark (variant Q). This changes pixels, so it has to happen before the manifest binds them.
- 3Worker
Fingerprint
A 256-bit PDQ hash of the watermarked image, with PDQ’s quality score, for the case where both the manifest and the watermark are gone.
- 4API
Build the manifest
A
c2pa.createdaction with the IPTC source type for AI output, ac2pa.watermarked.boundaction, and a soft-binding assertion carrying the watermark payload, so each can be found from the other. - 5Signer
Name the company
If the company has proved its domain and its name has been approved, the signer adds a CAWG identity assertion with the role
cawg.publisher, signed with the company’s identity certificate. - 6Signer
Sign
c2pa-rs signs the claim with ES256 through Cloud KMS. The private key is in an HSM and never leaves it; KMS’s DER signature is converted to the fixed-width form COSE expects.
- 7Signer
Validate
c2pa-rs validates the signed file before it leaves the signer, and an invalid one is refused. The verdict and the full crJSON report are kept with the output’s record.
- 8API
Record
A registry record with a ULID: the hashes, the watermark payload, the fingerprint, the validation report and the model, but never the picture. A signed copy is kept for 30 days only when asked for.
curl https://signunder-staging-api.fly.dev/v1/sign \
-H "Authorization: Bearer $SIGNUNDER_API_KEY" \
-F "file=@picture.png;type=image/png" \
-o picture.signed.pngServe the returned bytes unchanged: re-encoding breaks the signature, though the watermark and fingerprint still identify the picture. SDKs: pip install signunder and dotnet add package Signunder. Full reference in the docs.
3
What goes in the manifest
Simplified from what ImageSigner builds and the signer completes. The hard binding (c2pa.hash.data) and the signature are added by c2pa-rs. The identity assertion appears only once the company’s name is approved.
{
"claim_generator_info": [
{ "name": "SignUnder", "version": "0.1.0", "specVersion": "…" }
],
"assertions": [
{
"label": "c2pa.actions",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType":
"http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
},
{ "action": "c2pa.watermarked.bound" }
],
"allActionsIncluded": true
}
},
{
"label": "c2pa.soft-binding",
"data": {
"alg": "com.adobe.trustmark.Q",
"blocks": [{ "scope": {}, "value": "<the 61-bit payload, 8 bytes>" }]
}
},
{
"label": "cawg.identity",
"data": {
"signer_payload": { "role": ["cawg.publisher"], "sig_type": "cawg.x509.cose", "…": "…" },
"signature": "…"
}
}
]
}4
How a picture is found again
POST /public/v1/{company}/verify needs no key and is limited to 30 requests a minute per address. It looks only among that company’s own records, and says nothing about anyone else’s pictures.
- First
The manifest
If Content Credentials are present, c2pa-rs validates them and the manifest leads to the record. The answer says whether the picture has been altered since signing.
- Then
The watermark
If the manifest is gone, the worker decodes TrustMark from the pixels. The 61-bit payload matches one record exactly.
- Last
The fingerprint
If both are gone, the PDQ hash is compared with the company’s records. A match needs 31 or fewer of the 256 bits to differ, and the answer says it is weaker evidence.
The fingerprint search scans the company’s records today; it will get an index before any company has many thousands of them.
5
Keys and certificates
Two separate chains of trust end up in one file: one says the claim was signed by a conforming product, the other says which company published it.
The claim signature
- 1
C2PA trust list
Certificate authorities recognised by the C2PA
- 2
Certificate authority
Issues a claim-signing certificate once the product passes the conformance programme
- 3
Claim-signing key
ES256 in Google Cloud KMS, HSM protection, Frankfurt. The CSR is built by the sidecar’s kms-csr tool, signed by KMS itself
Until the listing, signatures validate but viewers show them as unrecognised. The local stack uses a committed development key for the same reason.
The company’s name
- 1
SignUnder Identity CA
Its key in Cloud KMS HSM
- 2
Company identity certificate
Issued after a DNS TXT record proves the domain and the name is approved by hand
- 3
CAWG identity assertion
“Published by Acme Studio (acme.com, verified by SignUnder)”
Validators report the assertion as well-formed but untrusted, because no shared identity trust list includes this CA yet.
The services on Fly.io reach KMS through workload identity federation, so no long-lived cloud credential is stored anywhere.
6
Proxy mode
For fal and Replicate, point the client’s base URL at SignUnder (/proxy/fal/queue/…, /proxy/replicate/v1/…). Your provider key passes through and is never stored; the SignUnder key travels in X-SignUnder-Key.
Follow-up URLs in each response are rewritten to come back through the proxy. When a finished result is fetched, each image is downloaded from the provider’s own media hosts (HTTPS, no redirects, 32 MB at most), put through the same pipeline, and the result points at the signed copy instead. If an image cannot be signed, the call fails with a 502 rather than passing an unmarked picture through.
7
The rules engine
Each jurisdiction and platform is a versioned JSON rule pack. Every rule cites its primary source and the date it was read, and a malformed pack refuses to load. POST /public/v1/rules/evaluate takes where and how the content is published and returns the findings and the pack versions used, which is what the checker runs on.
A weekly job compares every source with its last accepted snapshot and opens an issue when one changes. Sources that refuse automated reads are checked by hand each month.
8
Security
- Signing keys never leave the HSM; nothing signs with a key held in software outside the development stack.
- Mutual TLS 1.3 between the API, the signer and the worker, from a private CA; each server accepts only the API’s certificate.
- API keys are stored as SHA-256 hashes, scoped, revocable and rate limited. Another company’s record reads as not found.
- Internal error details are logged, not returned; callers get a reference to quote.
- Images and lockfiles are scanned for known vulnerabilities, and an OWASP Top 10 review has been done on the API.
9
Measured, and not yet
On 13 October 2026, on four photographs, the TrustMark watermark survived all five transforms we tried: resizing to 50%, JPEG at quality 50, a 10% crop, a framed screenshot and re-encoding PNG to WebP. The PDQ fingerprint survived resizing and recompression but not the crop or the screenshot, so it is a fallback for some cases only.
Not yet measured: what each platform does to the manifest on upload and download, and load beyond staging. Images only for now: no video, audio or text.
10
Run it yourself
You need .NET 10, Rust, Python with uv, protobuf and Docker. The local stack signs with a development key and certificate committed on purpose, so its output validates but shows as unrecognised. To sign for real you need your own key in a KMS and a certificate from a CA on the C2PA trust list.
git clone https://github.com/AivaStack/signunder
cd signunder
make up # API, signer, worker and Postgres in Docker, signing with the development key
make sample # sign the fixtures and save each with its validation report