Every TensorFlow team now downloads model weights the way it downloads packages: a from_preset() call, a Hugging Face pull, a checkpoint a partner emailed over. That is a supply chain, and in 2024-2025 it became an audited one. Enterprise security reviews increasingly ask the same three questions before a model goes to production: where did this artifact come from, has it been modified, and can it execute code when we load it? This tutorial shows how to answer all three on the TensorFlow and Keras stack - serialization formats and their actual risk, safe_mode and custom objects, scanning untrusted artifacts, signing and verifying models with Sigstore, and wiring verification into CI and into your serving containers.
Why model artifacts are different from ordinary data
A .pkl, a legacy .h5 with a Lambda layer, or a SavedModel with custom ops are not inert data files. They contain instructions that the loader will happily run in your training cluster or your serving pod - often as root, often with cloud credentials attached. The well-known attack classes are:
- Deserialization execution. Python pickle payloads embedded in checkpoints; arbitrary Python inside a serialized
Lambdalayer. - Artifact substitution. A mirror, a stale cache, or a compromised bucket hands you weights that are not the ones you evaluated. Nothing in a raw
.kerasfile tells you who built it. - Backdoored weights. Weights that behave normally on your eval set and misbehave on a trigger input. Scanning will not catch this; provenance and evaluation will.
- Dependency drift. The model loads fine, but pulls a custom-op wheel or a preset-fetching library you never reviewed.
The mitigations differ per class, so treat them separately rather than buying one tool and declaring the problem solved.
Step 1: choose a serialization format on purpose
| Format | Loads code? | Use it for |
|---|---|---|
.keras (Keras 3 zip archive) | Config JSON + weights; no arbitrary code while safe_mode=True | Default for Keras 3 training artifacts |
.weights.h5 | Weights only | Safest exchange when both sides share model code |
safetensors | Tensors only, no execution | Cross-framework weight exchange |
TF SavedModel | Serialized graph, plus custom ops if present | Serving via TF Serving / Vertex |
Legacy .h5 full model, pickles | Yes, via Lambda / pickle | Avoid for anything untrusted |
In Keras 3, saving the whole model is the archive format:
model.save("churn_v7.keras") # config + weights, no pickle
model.save_weights("churn_v7.weights.h5") # weights only
Step 2: load untrusted models in safe mode
Keras 3 defends deserialization with safe_mode, which is on by default and refuses to reconstruct Lambda layers (the classic arbitrary-code vector):
import keras
# Default. A Lambda layer in the archive raises instead of executing.
model = keras.saving.load_model("vendor_model.keras")
# Only for artifacts you built yourself and can re-derive:
model = keras.saving.load_model("ours.keras", safe_mode=False)
Two rules we enforce on client projects:
safe_mode=Falsenever appears in code that loads a third-party artifact. If loading fails in safe mode, that is the security control firing, not a bug to work around.- Replace
Lambdalayers with real layers. A three-line subclass is reviewable, diffable, and safe to deserialize:
@keras.saving.register_keras_serializable(package="deepadvisors")
class ScaleByConstant(keras.layers.Layer):
def __init__(self, scale=1.0, **kwargs):
super().__init__(**kwargs)
self.scale = scale
def call(self, x):
return x * self.scale
def get_config(self):
return {**super().get_config(), "scale": self.scale}
Custom objects still execute your code path, so the registered class must come from a package you pin and review - the archive only names it, it does not carry it.
Step 3: scan artifacts before they touch a GPU
Scanning is cheap and catches the boring, common cases. Run it at the ingestion boundary - the job that copies a vendor artifact into your internal registry - not at load time in production.
pip install modelscan
modelscan -p ./incoming/vendor_model.keras -r json -o scan.json
A minimal ingestion gate:
import json, subprocess, sys, zipfile, pathlib
def scan(path: str) -> None:
out = subprocess.run(["modelscan", "-p", path, "-r", "json", "-o", "/tmp/scan.json"],
capture_output=True, text=True)
report = json.loads(pathlib.Path("/tmp/scan.json").read_text())
issues = report.get("issues", [])
if issues:
sys.exit(f"{len(issues)} unsafe operator(s) in {path}: {issues[:3]}")
def inspect_keras_archive(path: str) -> dict:
"""A .keras file is a zip - read config.json without constructing anything."""
with zipfile.ZipFile(path) as z:
return json.loads(z.read("config.json"))
scan("incoming/vendor_model.keras")
cfg = inspect_keras_archive("incoming/vendor_model.keras")
layers = {l["class_name"] for l in cfg["config"]["layers"]}
assert "Lambda" not in layers, "Lambda layer present - reject"
print(sorted(layers))
Reading config.json straight out of the zip is the trick worth remembering: you can audit an unknown Keras artifact's full layer list, shapes, and custom-object names without ever calling load_model.
For SavedModel directories, inspect the signatures and look for unexpected custom ops before serving:
saved_model_cli show --dir ./exported/1 --all
Step 4: sign models so you know what you deployed
Scanning answers "is this file dangerous?". Signing answers "is this the artifact our pipeline produced?" - the question that matters for the substitution and backdoor cases. Google's model_signing library (Sigstore-backed, part of the OpenSSF AI work) signs the whole artifact, directory included, and produces a verifiable signature bundle:
pip install model-signing
# In CI, keyless via workload identity (OIDC):
model_signing sign ./exported/churn_v7.keras
--signature churn_v7.sig
# At ingestion or at deploy time:
model_signing verify ./exported/churn_v7.keras
--signature churn_v7.sig
--identity "https://github.com/acme/ml-pipelines/.github/workflows/train.yml@refs/heads/main"
--identity_provider "https://token.actions.githubusercontent.com"
The identity flags are the whole point: verification is not just "a valid signature exists", it is "this artifact was signed by our training workflow on main". A signature with no asserted identity buys you almost nothing.
If you cannot adopt Sigstore yet, a hash manifest plus your existing artifact registry's immutability and a recorded digest in the model card is a reasonable interim control. Record the digest next to the eval metrics - see our explainability and model cards tutorial - so that "the model we measured" and "the model we shipped" are provably the same bytes.
Step 5: enforce it in the pipeline, not in a wiki
Make the gate a pipeline step. In a TFX-style flow (see our continuous training tutorial), the natural place is between Trainer/Evaluator and Pusher: sign after blessing, verify before push. In plain CI:
# .github/workflows/release-model.yml (fragment)
permissions:
id-token: write # required for keyless Sigstore signing
contents: read
jobs:
release:
steps:
- run: modelscan -p artifacts/churn_v7.keras -r json -o scan.json
- run: python ci/fail_on_scan_issues.py scan.json
- run: python ci/smoke_eval.py artifacts/churn_v7.keras # golden tests
- run: model_signing sign artifacts/churn_v7.keras --signature churn_v7.sig
- run: gcloud storage cp artifacts/churn_v7.keras churn_v7.sig gs://acme-models/churn/v7/
Then verify in the serving image's entrypoint, before the server binds a port:
# entrypoint.py
import subprocess, sys
def verify_or_die(artifact: str, sig: str, identity: str, issuer: str) -> None:
r = subprocess.run(["model_signing", "verify", artifact, "--signature", sig,
"--identity", identity, "--identity_provider", issuer])
if r.returncode != 0:
sys.exit("model signature verification failed - refusing to serve")
Fail closed. A serving pod that starts with an unverified model is the exact outcome the control exists to prevent. The deployment mechanics around this are in our TensorFlow Serving tutorial.
Step 6: the rest of the checklist
- Pin and hash your presets.
from_preset("gemma2_2b_en")reaches the network. Mirror presets into an internal bucket, pin the revision, and verify digests, the same way you vendor pip wheels. Our Gemma fine-tuning tutorial assumes internet access; production pipelines should not have it. - Record licence and origin. Open-weight models carry usage terms. Capture the preset name, source URL, licence, and digest in the model card at training time - reconstructing it during an audit is painful.
- Separate credentials. The job that loads a third-party artifact should not hold your data-warehouse or deployment credentials.
- Keep the backdoor question honest. Signing proves origin, not benignity. For high-stakes models built on donated or third-party weights, budget for a held-out eval your vendor never saw, plus trigger-style robustness probes.
- Log the digest at inference. Every prediction log line should carry the model digest, so an incident can be scoped to exact artifacts rather than "sometime last quarter".
A workable maturity ladder
You do not need all of this in week one. In practice we phase it:
- Ban
safe_mode=FalseandLambdalayers in reviewable code; standardise on.keras/safetensors. - Add a scan-and-inspect gate at the ingestion boundary for every external artifact.
- Sign in CI, verify at deploy, fail closed.
- Record digests, licences, and provenance in model cards and inference logs.
- Add adversarial/held-out evaluation for third-party weights in high-stakes paths.
Steps 1 and 2 are an afternoon. Steps 3 and 4 are a sprint, and they are the ones that satisfy security reviews and emerging AI governance requirements.
Need help hardening your ML supply chain? DeepAdvisors' senior TensorFlow consultants implement signing, verification, and provenance for training and serving pipelines without slowing your teams down. Get in touch to talk through your setup.