Skip to main content
BlogSecurity

Hugging Face Security Incident 2026: Treat Datasets as Code

A close reading of Hugging Face's July 2026 disclosure: a malicious dataset, a remote-code loader, config template injection, and the controls and rotation order that limit the damage.

Matthews Wong

Software Engineer

October 202612 min read
Hugging Face Security Incident 2026: Treat Datasets as Code

Key Takeaway

Hugging Face's July 2026 incident began with a malicious dataset that ran code through a remote-code loader and template injection in its configuration. From the worker, the attacker reached the node, then cluster credentials. Defenders should treat uploaded datasets and configs as untrusted programs, isolate workers, restrict admission and rotate tokens.

On 16 July 2026 Hugging Face published a short disclosure that most of the ML world will have read as someone else's problem. A dataset, the thing every training script downloads without a second thought, had been used to run code inside the platform's own infrastructure. I read the post twice before I noticed what it does not contain: no CVE, no exact dates, no named library version. The detail is thin, and the useful part is the shape of the attack.

This is not a re-telling of the incident, and I have no inside knowledge of Hugging Face's systems. I read the disclosure itself, the release notes and docs for the tools it implicates, and the Kubernetes and Jinja documentation for the controls that apply. Every claim below traces to one of those pages, and where the disclosure is silent I say so rather than fill the gap. No exploit payloads appear here, only the defender's side.

What the disclosure says, and what it leaves out

The post describes a malicious dataset that abused two code-execution paths in dataset processing: a remote-code dataset loader and template injection in a dataset configuration. The attacker ran code on a processing worker, escalated to node-level access, harvested cloud and cluster credentials, and then moved laterally into several internal clusters. Hugging Face says that movement happened over a weekend and that it detected and responded earlier in the week of publication.

The outcome it reports is narrower than the path. The attacker got unauthorised access to a limited set of internal datasets and several service credentials. Hugging Face says it found no evidence of tampering with public, user-facing models, datasets or Spaces, and that it verified its container images and published packages as clean. It is still assessing whether partner or customer data was affected and says it will contact affected parties directly.

Two details deserve more attention than they got. The campaign was run by an autonomous agent framework, with many thousands of actions across short-lived sandboxes and command-and-control staged on public services, and the model behind it is unknown. And the incident was first surfaced through AI-assisted detection, where LLM-based triage correlated the signals. What is absent matters for how much you should infer: there are no dates, no library names, no version numbers and no cloud provider. Anything more specific than the paragraph above is speculation, including mine.

A dataset is an input to a program, and some inputs are programs

The useful reading is not 'datasets are dangerous'. It is that every stage between upload and the node is a place where data is interpreted, and each interpretation grants the uploader something. Laid out as a chain, the attack stops looking exotic. Each row below is a stage the disclosure names or implies, what that stage trusted, and the control that would have narrowed it.

StageWhat it trustedControl that narrows it
Dataset loaderThat a repository's own loading code is safe to executeNever opt in to remote code; name the packaged format yourself
Dataset configurationThat a string in the config is data, not a template to evaluateNo template engine on uploaded config; sandbox only if unavoidable
Processing workerThat code running in a pod stays in the podPod Security Admission and admission policy on the worker namespace
Node and cloud identityThat a container cannot read the node's cloud credentialsMetadata service hardening, least-privilege node roles
Internal clustersThat one cluster's credentials are not valid in anotherPer-cluster identities, short-lived credentials, rotation on suspicion

Read the right-hand column as a list of questions to ask of your own pipeline, not as a claim about what Hugging Face lacked. The disclosure does not say which of these controls were missing, only that it afterwards closed the code-execution paths, rebuilt nodes and added stricter admission controls. The pattern is also not new for ML specifically: Hugging Face's own Hub documentation says plainly that pickle, the default format for PyTorch weights, can execute arbitrary code at load time, and that its scanner is not foolproof. Datasets turned out to have the same property.

The remote-code loader: opt-in is a trust decision

A dataset loading script is Python that the dataset's author ships alongside the data. The Hugging Face datasets documentation for the main branch says that for security reasons the library does not run these scripts by default and that you must pass trust_remote_code=True to load a dataset that needs one. That flag is the whole boundary: it is a person, or a pipeline, saying 'run what this repository told me to run'. On a processing worker that ingests other people's datasets, that sentence is the vulnerability.

# 1. Find every place your own code opts in to running a dataset's code.
grep -rnE "trust_remote_code\s*=\s*True" --include="*.py" .

# 2. Know which datasets release each worker image really has. Release notes
#    for 4.0.0 list "Remove scripts altogether" and say trust_remote_code is
#    no longer supported - but the main docs page still describes scripts.
#    Believe the installed package, not either page.
pip show datasets | grep -i "^Version"

# 3. A third-party dataset with data files needs no code at all. Name the
#    packaged builder yourself so a repo-supplied script is never consulted.
python - <<'EOF'
from datasets import load_dataset

# Wrong: the repo decides how it gets loaded (script, builder, config).
# ds = load_dataset("some-org/some-dataset")

# Right: you decide the format; the repo only supplies bytes.
ds = load_dataset(
    "parquet",
    data_files="hf://datasets/some-org/some-dataset/data/train-00000-of-00001.parquet",
    split="train",
)
print(ds.num_rows)
EOF

There is a wrinkle I could not resolve from the pages I read. The 4.0.0 release notes for the datasets library list 'Remove scripts altogether' as a breaking change and state that trust_remote_code is no longer supported, yet the main-branch docs page still describes scripts and the flag. The disclosure does not say that the library was involved at all, or which version a worker ran. So the honest advice is the boring one: check the version installed in every worker image, and do not assume an old image or a pinned dependency has the newer behaviour.

Two practical rules follow. First, search your own code for the opt-in, because a flag set once in a helper function quietly applies to every dataset passing through it. Second, move the decision about format out of the repository: if the data is Parquet, JSON or CSV, load it with the packaged builder and a file path, so there is no code path in which the uploader's script is consulted. The block above does both. I have not run it against Hugging Face's stack or anyone else's, so test it on a dataset you control.

Put the grep in CI as a failing check on any change that adds trust_remote_code=True. A reviewer skimming a diff will wave through one extra keyword argument; a red build will not.

Template injection in configuration: the second path

The second path was template injection in a dataset's configuration. The disclosure names the class, not the engine, so I will not guess at which one was involved. The generic mechanism is the same everywhere: a config field is meant to be a string, someone passes it to a template renderer to fill in a name or a path, and a template language that can reach attributes and functions becomes a way to run code. Jinja is the common Python example, and its documentation is useful because it is candid about the limits of the fix.

# Wrong: a string from an uploaded config becomes a program.
from jinja2 import Environment

def render_name(cfg: dict, row: dict) -> str:
    return Environment().from_string(cfg["name_template"]).render(row=row)

# Better: Jinja's sandbox. Insecure access raises SecurityError, but the docs
# say plainly that "The sandbox alone is not a solution for perfect security."
from jinja2.exceptions import SecurityError
from jinja2.sandbox import ImmutableSandboxedEnvironment

SANDBOX = ImmutableSandboxedEnvironment()

def render_name_sandboxed(cfg: dict, row: dict) -> str:
    try:
        return SANDBOX.from_string(cfg["name_template"]).render(row=row)
    except SecurityError:
        raise ValueError("template rejected")  # reject the dataset, do not retry

# Best: no template engine at all. Substitution over a fixed key list cannot
# reach an attribute, a filter or a function, because there is nothing to reach.
ALLOWED_KEYS = ("id", "split", "language")

def render_name_plain(cfg: dict, row: dict) -> str:
    out = cfg["name_pattern"]
    for key in ALLOWED_KEYS:
        out = out.replace("@" + key + "@", str(row.get(key, "")))
    return out

The Jinja sandbox docs say the sandbox can be used to render untrusted templates, that insecure access raises SecurityError, and, in the same breath, that other exceptions may occur during rendering so the caller has to catch them. They also say 'The sandbox alone is not a solution for perfect security.' ImmutableSandboxedEnvironment is the stricter variant that blocks changes to the builtin list, set and dict. The code above shows the three tiers: the version that causes incidents, the sandboxed version, and the version with no template engine at all.

I prefer the third. A fixed list of substitution keys cannot reach an attribute, a filter or a function, so there is no sandbox to escape from and nothing to keep patched. The cost is real: you lose conditionals and loops in config, and users who liked them will complain. If you keep a template engine, treat a SecurityError as a reason to reject the whole dataset, never to retry with a looser environment.

From a worker pod to the node: where blast radius is decided

Code execution on a processing worker is bad but bounded. What turned it into an incident was the next step: node-level access, then cloud and cluster credentials, then lateral movement. The controls here are all Kubernetes-native and none are exotic. Pod Security Admission, stable since Kubernetes v1.25, applies the Pod Security Standards per namespace through labels. Its enforce mode rejects the pod, while audit and warn only record or tell the client. One detail from the docs bites people: enforce applies to Pod objects, not to Deployments or Jobs, and the page warns against exempting controller service accounts because that exempts anyone who can create the workload.

# Namespace that runs untrusted-input workers. Pod Security Admission, stable
# since Kubernetes v1.25. enforce rejects the Pod; warn only tells the client.
apiVersion: v1
kind: Namespace
metadata:
  name: dataset-workers
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted
---
# Your own rule on top, with ValidatingAdmissionPolicy (stable since v1.30).
# This one refuses any Pod that mounts a hostPath volume. My example, not the docs'.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: no-hostpath.example.com
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "!has(object.spec.volumes) || !object.spec.volumes.exists(v, has(v.hostPath))"
      message: "hostPath volumes are not allowed in dataset-workers"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: no-hostpath-binding.example.com
spec:
  policyName: no-hostpath.example.com
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: dataset-workers

The Baseline level of the Pod Security Standards forbids the pod-to-host bridges that matter most here: it requires hostNetwork, hostPID and hostIPC to be unset or false, requires securityContext.privileged to be unset or false, and forbids hostPath volumes outright. ValidatingAdmissionPolicy, stable since v1.30, lets you add your own rule in CEL, with validationActions of Deny, Warn or Audit. The manifest above enforces Baseline on a worker namespace, audits against Restricted, and adds a hostPath rule that is my own example, not one from the docs.

# If the nodes are EC2 instances: require IMDSv2 and keep the PUT response hop
# limit at 1 (the default), so a process inside a container cannot mint a
# metadata token. A request without a valid token then gets 401.
# Hugging Face did not say which cloud it runs on - this is one provider's lever.
aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1

The node's cloud identity is the other half. The disclosure says credentials were harvested but not from where, so I cannot say a metadata service was involved. If your nodes are EC2 instances, AWS documents that IMDSv2 is session-oriented, that a request without a valid token gets a 401 once tokens are required, and that the PUT response hop limit defaults to 1 at the IP protocol level. Setting tokens to required and leaving the hop limit at 1 is the lever, shown in the second block above. AWS also notes you might raise the hop limit for backward compatibility with container services, which is exactly the setting to audit.

The trade-offs, and what this does not fix

Every control above has a cost, and pretending otherwise is how these checklists get abandoned. Enforcing Baseline on a worker namespace will reject legitimate pods that use hostPath for a scratch volume or a device plugin, so you will spend a week finding them. Refusing remote code means some older datasets simply cannot be ingested, and the answer is to ask the author to convert to a standard format, not to carve an exception. Removing templating from config breaks a feature someone uses.

There is also a limit to what the disclosure lets you conclude. It does not say whether the exploited loader was in the open-source library, an internal fork or custom processing code. It gives no version, no date and no provider. Hugging Face also says its assessment of partner and customer data is still open. A defender who copies a control list from a disclosure this short is guessing which of the controls would have mattered, and should say so in the design review.

Do not read 'no evidence of tampering with public models, datasets or Spaces' as 'nothing to do'. It is a statement about what Hugging Face verified on its side. Your own tokens, your own cached datasets and any pipeline that pulled from the platform during the incident window are still yours to review, and the disclosure's own advice is to rotate access tokens and check recent account activity.

One more caveat concerns forensics. Hugging Face says its analysis was initially blocked by guardrails on hosted commercial models, so it ran the analysis on the open-weight model zai-org/GLM-5.2 on its own infrastructure, keeping attacker data and credentials inside its environment. It recommends having a capable self-hosted model vetted before an incident. That is a planning decision with its own costs, hardware and review, not something to improvise at the moment you need it.

What Hugging Face changed, and your equivalent

The remediation list in the disclosure is a better template than most vendor post-mortems because it is concrete. The table maps each item to what a team running its own dataset or model ingestion could do. The right-hand column is my translation, not Hugging Face's.

Hugging Face didYour equivalent
Closed the dataset code-execution paths used for initial accessRemove trust_remote_code and config templating from every ingestion path
Eradicated the foothold across affected clusters and rebuilt compromised nodesRebuild nodes from images instead of cleaning them; treat a node that ran untrusted code as burned
Revoked and rotated affected credentials, then began a broader precautionary rotationRotate in blast-radius order, starting with anything the worker or node could read
Added guardrails and stricter cluster admission controlsPod Security Admission plus a ValidatingAdmissionPolicy on worker namespaces
Improved detection and alerting for high-severity signalsAlert on a worker pod spawning shells, reaching metadata addresses or calling other clusters

If you use Hugging Face, the disclosure's own instruction is to rotate access tokens as a precaution and review recent activity on your account, and to report concerns to security@huggingface.co. If you run your own ingestion, rotate in this order, because the order is what limits damage if you are interrupted halfway.

  1. Credentials the worker could read directly: environment variables, mounted secrets and service account tokens.
  2. Credentials the node could read: its cloud role, instance profile and any registry or image pull secrets.
  3. Cluster credentials that worker or node identities could use against other clusters, including kubeconfigs and bearer tokens.
  4. Personal and CI access tokens for the Hub or any dataset source the pipeline pulled from, issued with the narrowest scope that works.
  5. Everything else, as a precaution, once the first four are done and the old values are confirmed dead rather than merely replaced.

What to do on Monday

None of this needs a project. It needs an afternoon, a list of where your pipeline ingests data you did not write, and the willingness to find out you have some.

  1. Run the grep for trust_remote_code across every repository and notebook that feeds a shared worker, and record the installed datasets version in each worker image.
  2. List every config field that reaches a template renderer. For each, decide: remove the engine, sandbox it, or confirm the field is never user-supplied.
  3. Label the namespace that runs ingestion with Pod Security Admission enforce at baseline and warn and audit at restricted, then read what the warnings say for a week before tightening.
  4. Check what a pod can reach from inside: the node's metadata endpoint, other clusters' API servers and mounted credentials. If you are on EC2, require IMDSv2 and confirm the hop limit.
  5. Write down your rotation order and who can execute it at 03:00. A checklist nobody has rehearsed is a document, not a control.

Then re-read the Hugging Face disclosure with your own pipeline in mind and mark each sentence as 'we are exposed', 'we are not' or 'we do not know'. The third pile is the real result.

The lesson I take is a rule, not a list: anything a stranger uploads that is later opened by a program is a program, whatever file extension it wears. Loaders, config templates and weights all fit. Decide the format yourself, give the interpreter nothing to evaluate, assume the worker will eventually run something hostile, and make sure that what it can reach from there is small, short-lived and rotatable.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What happened in the Hugging Face security incident of July 2026?

Hugging Face disclosed on 16 July 2026 that a malicious dataset abused a remote-code dataset loader and template injection in a dataset configuration to run code on a processing worker. The attacker escalated to node level, harvested cloud and cluster credentials and moved into several internal clusters. Hugging Face reports access to a limited set of internal datasets and service credentials.

Were public Hugging Face models, datasets or Spaces tampered with?

Hugging Face says it found no evidence of tampering with public, user-facing models, datasets or Spaces, and that it verified its container images and published packages as clean. It was still assessing whether partner or customer data was affected at the time of the disclosure. That is a statement about its own verification, so you should still review your own tokens and account activity.

What should I do if I use Hugging Face tokens?

Hugging Face recommends rotating any access tokens as a precaution and reviewing recent activity on your account. Concerns can be reported to security@huggingface.co. When you reissue tokens, give them the narrowest scope that still works and revoke the old values rather than leaving them alongside the new ones.

Is trust_remote_code=True dangerous?

Yes, it is an explicit decision to run code that a dataset repository supplies. The datasets docs say scripts are not run by default for security reasons and require that flag. The 4.0.0 release notes list scripts as removed and the flag as unsupported, so check the version in each worker image and search your code for the opt-in.

How do I stop a compromised worker from reaching the node?

Label the worker namespace with Pod Security Admission enforce at baseline, which forbids privileged containers, host namespaces and hostPath volumes, and add a ValidatingAdmissionPolicy for your own rules. Then limit what the node can hand out: require IMDSv2 with a hop limit of 1 on EC2, and give node roles least privilege. The disclosure does not name Hugging Face's cloud, so treat these as general controls, not a diagnosis.