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

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

Related guides

Describes technical measures. Not a legal assessment.