How to add C2PA Content Credentials to AI-generated images in Python
Published 12 October 2026
A C2PA manifest, also called Content Credentials, is signed metadata inside an image file that says how the file was made. For AI-generated images, the useful statement is that a trained model created the image. This guide adds that statement to a JPEG with the open-source c2pa-python library, reads it back, and then covers what changes for production. Every line of code below was run as shown.
1. Install the libraries
You need Python 3.10 or newer. c2pa-python wraps the reference C2PA implementation (c2pa-python, the C2PA library for Python (Content Authenticity Initiative)), and cryptography makes the test key and signs with it.
pip install c2pa-python cryptography
2. Make a test signing certificate
Every C2PA manifest is signed, and the signature carries a certificate chain. For a first try, make your own: a test certificate authority, and a signing certificate it issues. C2PA expects the signing certificate to be a leaf certificate, not a certificate authority, with the digital signature key usage and an extended key usage it accepts. This script writes chain.pem and signer.key:
"""Makes a throwaway test CA and an ES256 signing certificate for C2PA.
For trying C2PA locally only: validators will report this signer as
untrusted, because the CA is not on the C2PA trust list.
"""
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
now = datetime.now(timezone.utc)
def name(common_name: str) -> x509.Name:
return x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example AI Ltd"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
# 1. A test certificate authority.
ca_key = ec.generate_private_key(ec.SECP256R1())
ca_cert = (
x509.CertificateBuilder()
.subject_name(name("Example Test CA"))
.issuer_name(name("Example Test CA"))
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=True,
crl_sign=True, encipher_only=False, decipher_only=False,
),
critical=True,
)
.sign(ca_key, hashes.SHA256())
)
# 2. The signing certificate, issued by that CA. C2PA wants a leaf (not a
# CA) with digitalSignature and an extended key usage it accepts.
signer_key = ec.generate_private_key(ec.SECP256R1())
signer_cert = (
x509.CertificateBuilder()
.subject_name(name("Example AI image signer"))
.issuer_name(ca_cert.subject)
.public_key(signer_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=90))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=False,
crl_sign=False, encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.EMAIL_PROTECTION]), critical=False)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(signer_key.public_key()), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
# The chain file holds the signing certificate first, then the CA.
with open("chain.pem", "wb") as f:
f.write(signer_cert.public_bytes(serialization.Encoding.PEM))
f.write(ca_cert.public_bytes(serialization.Encoding.PEM))
with open("signer.key", "wb") as f:
f.write(signer_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
print("Wrote chain.pem and signer.key")
3. Sign an AI-generated image
The manifest holds one c2pa.actions assertion. Its action is c2pa.created, and its digitalSourceType is trainedAlgorithmicMedia, the IPTC term for media made by a trained AI model (IPTC digital source type: trainedAlgorithmicMedia). The library calls your sign function with the bytes to sign, so the private key can live wherever you keep keys. Put your generated image next to the script as generated.jpg:
"""Adds a signed C2PA manifest to an AI-generated image, declaring it as
made by a trained model, then reads the result back."""
import json
import c2pa
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
with open("signer.key", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
with open("chain.pem", "rb") as f:
chain_pem = f.read()
def sign(data: bytes) -> bytes:
# ES256: ECDSA over P-256 with SHA-256, returned DER-encoded.
return private_key.sign(data, ec.ECDSA(hashes.SHA256()))
manifest = {
"claim_generator": "example-ai-app/1.0",
"title": "AI-generated image",
"assertions": [
{
"label": "c2pa.actions",
"data": {
"actions": [
{
"action": "c2pa.created",
# IPTC's term for media made by a trained AI model.
"digitalSourceType": "trainedAlgorithmicMedia",
"softwareAgent": "Example image model",
}
]
},
}
],
}
# No timestamp authority here (None); use one in production, so the
# manifest stays valid after the signing certificate expires.
signer = c2pa.Signer.from_callback(sign, c2pa.C2paSigningAlg.ES256, chain_pem.decode(), None)
context = c2pa.Context.builder().with_signer(signer).build()
builder = c2pa.Builder(json.dumps(manifest), context=context)
with open("generated.jpg", "rb") as source, open("generated-marked.jpg", "w+b") as dest:
builder.sign("image/jpeg", source, dest)
# Read it back: the manifest store as JSON.
reader = c2pa.Reader("generated-marked.jpg")
store = json.loads(reader.json())
active = store["manifests"][store["active_manifest"]]
for assertion in active["assertions"]:
if assertion["label"].startswith("c2pa.actions"):
print(assertion["data"]["actions"][0])
It writes generated-marked.jpg and prints the action it reads back:
{'action': 'c2pa.created', 'softwareAgent': 'Example image model', 'digitalSourceType': 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia'}
The short name is expanded to the full IPTC address in the signed manifest.
4. Check what a validator says
Read the file's validation results with the same library:
import c2pa, json
store = json.loads(c2pa.Reader("generated-marked.jpg").json())
print(store["validation_state"], [s["code"] for s in store["validation_status"]])
With the test certificate, this prints Valid ['signingCredential.untrusted']. The signature checks out and nothing was altered, but the signer is not one the validator trusts, because the test certificate authority is not on the C2PA trust list. You can also drop the file on the free checker to see the manifest reported.
5. What changes for production
- A trusted certificate. Validators trust signing certificates from certificate authorities on the C2PA trust list, and those authorities issue them to products that have been through the C2PA conformance program (C2PA, the Coalition for Content Provenance and Authenticity). Until you have one, your manifests read as validly signed by an unrecognised signer.
- Keep the key safe. Keep the private key in a key management service or hardware keystore, and sign through the callback. The key never needs to sit next to the images.
- A timestamp authority. Pass a timestamp authority URL instead of
None, so the manifest still validates after the signing certificate expires. - A watermark as well. The manifest is metadata, and many platforms strip metadata on upload. An invisible watermark in the pixels, such as Adobe's open-source TrustMark, can carry an identifier that finds the manifest again. C2PA calls this a soft binding (Adobe TrustMark: using TrustMark with C2PA). Why Content Credentials disappear covers this.
- Test your own pipeline. Resizers and content delivery networks may rewrite the file after you sign it. Check the file your users actually receive.
Doing this with Tellmark instead
Tellmark does each of these steps in one call. It signs the manifest on its own service, so you hold no signing key, embeds a TrustMark watermark, and keeps the record a stripped file can be traced back to. The image never leaves your machine: only its hash and the manifest's metadata are sent. The quick start shows the call. The API is in early access, and its certificate is not yet on the C2PA trust list either.
Sources
- c2pa-python, the C2PA library for Python (Content Authenticity Initiative)
- C2PA, the Coalition for Content Provenance and Authenticity
- IPTC digital source type: trainedAlgorithmicMedia
- Adobe TrustMark: using TrustMark with C2PA
Related guides
- C2PA and invisible watermarks: how they differ, and why AI images use both
- Why Content Credentials disappear after upload, and how to keep them
- Article 50 of the EU AI Act: marking and labelling AI-generated content
Describes technical measures. Not a legal assessment.