Sign an AI Model and Attest Its Provenance
To sign an AI model, hash every file and sign the list. The OpenSSF model_signing CLI (1.1.1 on PyPI) does both and writes a Sigstore bundle, model.sig, whose in-toto statement lists each file’s SHA-256; verification fails on any changed, added or missing file. In CI, sign keyless with the workflow’s OIDC identity, add SLSA build provenance with actions/attest (Build L2 on GitHub, L3 with an isolating reusable workflow), verify both before the model loads, and link both from the CycloneDX AIBOM as externalReferences of type digital-signature and attestation, with the expected signer identity in the comment. Watch the flag spelling: 1.1.1 uses underscores (--public_key), while the README on main already uses hyphens.
To sign an AI model, you hash every file in the model folder and sign that list. The OpenSSF model_signing CLI does both and writes a Sigstore bundle, model.sig. Add SLSA build provenance from your CI platform, verify both before the model loads, and link both from the AIBOM’s externalReferences. Below, Northwind Health’s Triage Assistant goes through each step, including what a failed verification looks like.
Who this is for: AppSec and AI-security engineers who own the model release pipeline, and SOC analysts who need to check that what is running is what was released.
What we ran and what we did not. We installed model_signing 1.1.1 from PyPI in a clean Python 3.13 environment and ran the key-based sign and verify commands on three placeholder files. Every “Signing succeeded”, “Verification succeeded” and “Verification failed” line below is real output from that run. The Sigstore keyless signing, the GitHub Actions workflow and the gh attestation verify commands need a real OIDC identity and repository, so they are marked illustrative: the syntax is checked against the tools’ own documentation and help, but we did not run them.
What you will produce
| Artefact | Made by | Format | Linked from the AIBOM as |
|---|---|---|---|
model.sig | model_signing 1.1.1 | Sigstore bundle (application/vnd.dev.sigstore.bundle.v0.3+json) holding a DSSE envelope with an in-toto statement, predicate https://model_signing/signature/v1.0 | externalReferences type digital-signature |
triage-assistant-2.3.0.intoto.jsonl | actions/attest@v4 | Sigstore bundle with an in-toto statement, predicate https://slsa.dev/provenance/v1 | type attestation |
| The AIBOM | Northwind’s release job | CycloneDX 1.7 ML-BOM, itself signed | Published with the release |
Step 1: install the CLI, and two traps
python3 -m venv .venv && . .venv/bin/activate
pip install model-signing==1.1.1
model_signing --version
# model_signing, version 1.1.1
Trap 1: a missing dependency. In our clean environment the CLI did not start until we added one package. The first run failed with ModuleNotFoundError: No module named 'OpenSSL'; pip install pyopenssl fixed it.
Trap 2: flag spelling. Version 1.1.1, released on 10 October 2025 and still the latest on PyPI, spells multi-word flags with underscores: --private_key, --public_key, --identity_provider, --use_ambient_credentials. The README on the project’s main branch already uses hyphens for a release that is not out yet. With 1.1.1, the README’s spelling fails:
Error: No such option '--public-key'. Did you mean '--public_key'?
Pin the version you install, and check model_signing verify sigstore --help before you copy a command.
Step 2: sign the model folder
For a run you can repeat offline, sign with a local P-256 key. In CI you will sign keyless with Sigstore instead (step 4); the file layout and the verification behaviour are the same.
openssl ecparam -name prime256v1 -genkey -noout -out key.priv
openssl ec -in key.priv -pubout -out key.pub
model_signing sign key triage-assistant --private_key key.priv --signature model.sig
# Signing succeeded
Our triage-assistant/ folder held three placeholder files: model.safetensors, config.json and tokenizer.json. A real Northwind release would hold the merged weights. The digests below are of our placeholder bytes, not of a model.
What is inside model.sig
The file is a Sigstore bundle with three keys: mediaType (application/vnd.dev.sigstore.bundle.v0.3+json), verificationMaterial and dsseEnvelope. The envelope’s payload type is application/vnd.in-toto+json. Decoded, the payload is the statement that was signed:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "triage-assistant",
"digest": { "sha256": "4350cc606319ffb504ff4478854632e8c668bdb2ff8bc0de71570e58be053105" }
}
],
"predicateType": "https://model_signing/signature/v1.0",
"predicate": {
"serialization": {
"method": "files",
"hash_type": "sha256",
"ignore_paths": [".gitattributes", ".git", ".github", ".gitignore"],
"allow_symlinks": false
},
"resources": [
{ "algorithm": "sha256", "name": "config.json", "digest": "3dfc3eaf456e1e5c96160026f7291f77292db3f165356bc5539c0f0174b17428" },
{ "algorithm": "sha256", "name": "model.safetensors", "digest": "c2e224cdf0193cde5ce821bdd728331643e614e6bd8987d2f01f8079b122976b" },
{ "algorithm": "sha256", "name": "tokenizer.json", "digest": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" }
]
}
}
Three things to notice:
- The subject is the folder name and one digest. The per-file SHA-256 digests are in
predicate.resources. (The project README describes the subjects as file and digest pairs; the 1.1.1 output we got puts them inresources.) - Git files are ignored by default, and the signature file itself is left out, so
model.sigcan live inside the folder it signs. We checked: signing with--signature triage-assistant/model.sigand verifying the same way succeeds. - The
model.safetensorsdigest here should equal the model’shashesvalue in the AIBOM. That comparison ties the signature to the AIBOM. (Northwind’s published AIBOM uses a placeholder hash, so it will not match our placeholder file.)
Step 3: verify, and what failure looks like
model_signing verify key triage-assistant --public_key key.pub --signature model.sig
# Verification succeeded (exit status 0)
Then we broke it on purpose, one change at a time, and restored the folder between tests:
| What we changed | Output (real; long digests shortened with …) | What it tells you |
|---|---|---|
| Appended one byte to model.safetensors | Verification failed with error: Signature mismatch: ['Hash mismatch for \'model.safetensors\': Expected \'Digest(algorithm=\'sha256\', digest_value=b\'\\xc2\\xe2$…\')\', Actual \'Digest(algorithm=\'sha256\', digest_value=b\'aH\\xfb1…\')\''] | The weights were changed after signing |
| Dropped adapter_model.bin into the folder | Verification failed with error: Signature mismatch: ["Extra files found in model 'triage-assistant': adapter_model.bin"] | Something was added: here, a pickle file a loader might fall back to |
| Deleted tokenizer.json | Verification failed with error: Signature mismatch: ["Missing files in model 'triage-assistant': tokenizer.json"] | The release is incomplete |
| Verified with a different public key | Verification failed with error: Key mismatch: The public key hash in the signature's verification material does not match the provided public key. | Someone else signed it, or you have the wrong key |
| Used the README’s --public-key spelling | Error: No such option '--public-key'. Did you mean '--public_key'? | Flag spelling of a newer, unreleased version (exit status 2) |
Every failure exits with status 1 (2 for the usage error), so a script can stop on it. The extra-file case matters most. A .bin file next to safetensors weights is exactly what a loader that falls back to pickle would pick up, as the AIBOM security and runtime section explains for vLLM. Version 1.1.1 also has --ignore_unsigned_files; leave it off in a deploy gate, or this check disappears.
Step 4: sign in CI with Sigstore, and add SLSA provenance
In the release pipeline, nobody holds a key. The job signs with its own workload identity, and the same job asks GitHub for SLSA build provenance. This workflow is illustrative; the permissions and inputs follow the actions/attest README and the model_signing 1.1.1 help.
# .github/workflows/release-model.yml (illustrative: not run)
name: release-model
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write # OIDC token: Sigstore signing and the attestation
attestations: write # store the provenance with GitHub
artifact-metadata: write # required by actions/attest v4
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# Northwind merges the LoRA adapter into the base weights here and writes
# model.safetensors, config.json and the tokenizer files to triage-assistant/.
- name: Build release folder
run: ./scripts/build-release.sh triage-assistant
- name: Sign the model files (OpenSSF model signing, Sigstore keyless)
run: |
pip install model-signing==1.1.1 pyopenssl
model_signing sign sigstore triage-assistant \
--signature triage-assistant/model.sig \
--use_ambient_credentials
- name: SLSA build provenance
id: provenance
uses: actions/attest@v4
with:
subject-path: 'triage-assistant/*'
- name: Keep the provenance next to the release
run: cp "${{ steps.provenance.outputs.bundle-path }}" triage-assistant-2.3.0.intoto.jsonl
--use_ambient_credentialstellsmodel_signing1.1.1 to use the workflow’s OIDC token instead of opening a browser login. The CLI flag is off by default.- The signer identity becomes the workflow file at the ref that ran, in the form the project documents for GitHub Actions:
https://github.com/northwind-health-example/triage-assistant/.github/workflows/release-model.yml@refs/tags/v2.3.0, with identity providerhttps://token.actions.githubusercontent.com. A verifier needs both. - Privacy. Keyless signing against the public Sigstore instance records the signing certificate, which names the repository and workflow, in the public Rekor transparency log. For a private repository, that may publish names you would rather keep. The alternatives are key or certificate signing, or a private Sigstore instance with
--trust_config. actions/attest@v4with onlysubject-pathgenerates SLSA build provenance, signs it and stores it with GitHub. The glob makes every file in the folder a subject,model.sigincluded. Thebundle-pathoutput is the Sigstore bundle; we copy it totriage-assistant-2.3.0.intoto.jsonl, the name Northwind’s AIBOM links to. GitHub says this is SLSA Build Level 2 on its own, and Build Level 3 when the build runs in a reusable workflow that isolates it.- What the provenance covers. This job merges and packages the weights. The provenance says this workflow, at this commit, produced these digests. It does not cover fine-tuning on another machine. The guide to who can attest to what explains that boundary.
Step 5: verify both before the model loads
Verification happens where the model is deployed, not only in CI. These commands are illustrative: they need the real signature and attestation from step 4.
# Illustrative: needs a real Sigstore signature from the workflow above.
model_signing verify sigstore triage-assistant \
--signature triage-assistant/model.sig \
--identity "https://github.com/northwind-health-example/triage-assistant/.github/workflows/release-model.yml@refs/tags/v2.3.0" \
--identity_provider "https://token.actions.githubusercontent.com"
gh attestation verify triage-assistant/model.safetensors \
--repo northwind-health-example/triage-assistant \
--signer-workflow northwind-health-example/triage-assistant/.github/workflows/release-model.yml \
--bundle triage-assistant-2.3.0.intoto.jsonl
model_signing verify sigstorechecks the bundle against Sigstore’s trust root, checks that the certificate names the expected identity and provider, then re-hashes every file. If the identity or provider does not match, verification fails and the command exits non-zero, just as the key-based failures above did.gh attestation verifychecks the predicate typehttps://slsa.dev/provenance/v1by default.--signer-workflowpins which workflow must have produced it.--bundleverifies from the file published with the release, without asking GitHub’s API for it.- We did not run either, so we do not quote their failure messages.
Then make it a gate. The same check in the container entrypoint, so vLLM never starts on unverified weights:
#!/bin/sh
# Illustrative deploy gate: start vLLM only if the signature verifies.
set -eu
model_signing verify sigstore /models/triage-assistant \
--signature /models/triage-assistant/model.sig \
--identity "$EXPECTED_SIGNER" \
--identity_provider "https://token.actions.githubusercontent.com"
exec python -m vllm.entrypoints.openai.api_server \
--model /models/triage-assistant --load-format safetensors
The --load-format safetensors setting (vLLM 0.6.3 accepts it) closes the fallback the extra-file test showed.
Step 6: link the signature and provenance from the AIBOM
Northwind’s CycloneDX 1.7 AIBOM already links both from the model component:
[
{
"type": "model-card",
"url": "https://models.northwind-health.example/northwind/triage-assistant/2.3.0/MODEL_CARD.md"
},
{
"type": "digital-signature",
"url": "https://models.northwind-health.example/northwind/triage-assistant/2.3.0/model.sig",
"comment": "Sigstore bundle from OpenSSF model signing, produced in CI."
},
{
"type": "attestation",
"url": "https://ci.northwind-health.example/attestations/triage-assistant-2.3.0.intoto.jsonl",
"comment": "SLSA build provenance as an in-toto attestation."
},
{
"type": "distribution",
"url": "https://models.northwind-health.example/northwind/triage-assistant/2.3.0/model.safetensors"
}
]
Excerpt: the model component’s externalReferences, exactly as in the full file (519 lines pretty-printed), which is in the Pro AIBOM Template Library.
One improvement: a Sigstore signature cannot be verified without the expected identity and identity provider, so write them where the verifier will look. The same two references with the verification inputs in the comment:
[
{
"type": "digital-signature",
"url": "https://models.northwind-health.example/northwind/triage-assistant/2.3.0/model.sig",
"comment": "OMS signature (Sigstore bundle) made with model_signing 1.1.1 in CI. Verify with identity https://github.com/northwind-health-example/triage-assistant/.github/workflows/release-model.yml@refs/tags/v2.3.0 and identity provider https://token.actions.githubusercontent.com."
},
{
"type": "attestation",
"url": "https://ci.northwind-health.example/attestations/triage-assistant-2.3.0.intoto.jsonl",
"comment": "SLSA provenance v1 from GitHub artifact attestations (Build L2), signer workflow northwind-health-example/triage-assistant/.github/workflows/release-model.yml. Covers the release job, not fine-tuning."
}
]
We validated this against the CycloneDX 1.7 JSON schema inside the full Northwind BOM.
- SPDX 3.0.1 has no dedicated reference type, so Northwind’s SPDX file uses
externalRefType: otherwithcontentType: application/vnd.dev.sigstore.bundle.v0.3+json. That is the same media type our real bundle declared. The SPDX 3.0 AI profile example shows the full element. - Sign the AIBOM too. Otherwise anyone who can edit it can point it at a different signature.
- Check it with a linter. The AIBOM Validator (Pro) flags a model component without these links. Every field of the file is explained in the CycloneDX ML-BOM worked example.
Northwind’s release checks
| Check | How | Fails when |
|---|---|---|
| Model files match the signature | model_signing verify sigstore … | A file changed, appeared or disappeared, or the signer identity differs |
| Signer is the release workflow | --identity and --identity_provider | Someone else signed, for example a developer’s personal account |
| Files came from the attested build | gh attestation verify … --signer-workflow … | No SLSA provenance from that workflow covers this digest |
| AIBOM matches the files | Compare the model.safetensors digest in the signature with the model hashes in the AIBOM | The AIBOM describes different weights |
| Loader cannot fall back to pickle | --load-format safetensors | A swapped folder with a .bin file would still load |
Fictional example. Northwind Health, Triage Assistant and its datasets, services, scores and URLs are invented for illustration; the base model and open-source libraries are real.
The example GitHub organisation northwind-health-example and its workflow are part of the fictional example.
What signing and provenance do not tell you
A valid signature and valid provenance say the files are the ones a known workflow produced. They say nothing about whether the training data was lawful, whether the evaluation numbers are honest, or whether the model is fit for its use. Those claims live in the AIBOM, the model card and, where they exist, third-party certificates. The guide to who can attest to what sets out which party can vouch for which. When something goes wrong, the signature and provenance are how you prove which model was running; using an AIBOM in incident response covers that side. All guides by role are on the AIBOM Library hub.
Frequently asked questions
How do I sign a machine learning model with Sigstore?
Install the OpenSSF model signing package (pip install model-signing) and run model_signing sign on the model folder. By default it signs with Sigstore: it opens an OIDC login, gets a short-lived certificate for your identity and writes a Sigstore bundle, model.sig, that lists the SHA-256 of every file. In CI, pass --use_ambient_credentials (version 1.1.1 spelling) so it uses the workflow’s identity token instead of a browser login.
How do I verify a signed model?
Run model_signing verify on the model folder with --signature pointing at the bundle. For a Sigstore signature, version 1.1.1 also requires --identity and --identity_provider: the identity you expect to have signed it and the OIDC provider that vouched for it. For a key signature, pass --public_key. The command prints “Verification succeeded” and exits 0, or prints the mismatch and exits 1.
What does a failed model signature verification look like?
In our test of model_signing 1.1.1, a changed file gave “Signature mismatch” with a hash mismatch for that file, an added file gave “Extra files found in model”, a deleted file gave “Missing files in model”, and the wrong public key gave “Key mismatch”. Each exits with status 1, so a deploy script can stop on it.
How do I generate SLSA provenance for a model in GitHub Actions?
Add the actions/attest@v4 step after the model files are produced, with subject-path pointing at them, and grant the job id-token: write, attestations: write and artifact-metadata: write. With no predicate input it generates SLSA build provenance, signs it with Sigstore and stores it with GitHub. Verify it with gh attestation verify. GitHub says this alone is SLSA Build Level 2.
How do I link a model signature and provenance from a CycloneDX AIBOM?
In the model component’s externalReferences, add an entry of type digital-signature pointing at the signature bundle and one of type attestation pointing at the provenance. Put the identity and identity provider a verifier should expect in the comment, because the signature cannot be checked without them. In SPDX 3.0.1 use externalRef type other with a contentType instead.
Why do model_signing flags fail with “No such option”?
Version 1.1.1, the latest on PyPI as of October 2026, spells multi-word flags with underscores: --private_key, --public_key, --identity_provider, --use_ambient_credentials. The README on the project’s main branch already uses hyphens (--public-key) for a release that is not out yet. Pin the version you install and check model_signing verify sigstore --help.
Sources
- sigstore/model-transparency: OpenSSF model signing (model_signing), README on main
- sigstore/model-transparency README at tag v1.1.1
- model-signing on PyPI (1.1.1 released 10 October 2025)
- ossf/model-signing-spec: the OpenSSF Model Signing (OMS) specification
- Sigstore documentation (Fulcio, Rekor, keyless signing)
- in-toto Attestation Framework v1 (Statement, subject, predicate)
- actions/attest (v4) README
- actions/attest-build-provenance README (plans and repositories)
- GitHub Docs: artifact attestations (Build L2, L3 with reusable workflows)
- GitHub CLI manual: gh attestation verify
- SLSA build provenance (predicateType https://slsa.dev/provenance/v1)
- SLSA v1.2: security levels (Build L0 to L3)
- CycloneDX bom-1.7 JSON schema (declarations, externalReference types)
- vLLM v0.6.3 engine/arg_utils.py (--load-format)
Checked on 29 October 2026. PlayCISO is not affiliated with any standards body, accreditation body, vendor or project named here. This is general information, not legal advice.
Ready to practise the decisions these articles describe?
Run your first War Room free →One run free with a free account · more runs with Pro