Nix Flake Input Drift and Supply Chain Exposure Windows
Lock files alone won't stop supply chain attacks without review discipline.

Nix flakes promise reproducible builds through a locking mechanism, and that promise holds, but only for a narrower slice of the problem than most teams assume. The flake.lock file is auto-generated and committed alongside flake.nix. It pins every input to a specific git revision and a narHash, a content hash of the fetched source tree. If the upstream source changes without the hash changing, Nix refuses to build. That's a real guarantee, enforced mechanically, not a policy teams have to remember to follow.
The guarantee stops exactly at the boundary of what the lock file records. It does not guard how the input declarations in flake.nix are written. It does not guard how often a team refreshes the lock, or whether anyone looks at a lock-file diff before it reaches CI. Those three gaps are not failures of Nix's design. Nix was built to make a declared state reproducible, not to make the declaration process itself safe. The gaps are organizational: they live in how a team writes input URLs, how a pipeline is configured to run nix flake update, and whether a pull request gets a human's attention before it merges. Treating the lock file as a complete supply chain control, rather than one layer of one, is where exposure starts. The three sections that follow name the specific windows that open when those gaps go unmanaged.
The first drift vector: floating branch references in flake.nix declarations
The most direct exposure vector doesn't come from a tooling flaw. It comes from how a developer writes an input URL. An input declared as a branch name or a tag, rather than an immutable commit hash, turns every future push to that branch into an unreviewed code change that's simply waiting for the next lock refresh to pull it into the build.
PaperWM's flake.nix shows this pattern concretely. A security issue filed against the project on September 28, 2026 (Issue #28) quotes the live declarations: inputs."nixpkgs".url = github:NixOS/nixpkgs and inputs."nixpkgs-gnome".url = github:vitorpavani/nixpkgs/gnome-50-bump. Neither line pins a commit hash. A related issue, filed two days later on September 30, 2026 (Issue #33), covers the same flake.nix concern, showing the first report wasn't a one-off observation.
The two inputs don't carry equal risk, even though they share the same structural flaw. github:NixOS/nixpkgs points at an organization with wide maintainer oversight and a large, attentive community watching commits land. github:vitorpavani/nixpkgs/gnome-50-bump points at a single fork maintainer's account, a smaller and softer target for account takeover, and yet that branch supplies core desktop components to the downstream build. The exposure window has the same shape in both cases: it opens the moment the floating reference gets written into flake.nix, and it stays open until someone replaces it with an immutable revision and commits that change for review. But the odds of something landing in that window are not the same for both inputs, and a third-party staging branch deserves more scrutiny than its NixOS-maintained counterpart gets, not less.
The fix is a declaration habit, not a new tool. The recommended form pins the commit directly in the URL: inputs.nixpkgs.url = "github:NixOS/nixpkgs/<commit-hash>", with both flake.nix and the resulting flake.lock committed together. That's the exact structure a security review of PaperWM's flake recommended after finding the floating references. It costs nothing in build capability and closes the window.
The second drift vector: automated lock refreshes that run without review
A flake can have every input pinned correctly and still be undermined by how a pipeline handles the lock file afterward. Running nix flake update automatically in CI, with no human looking at the resulting diff before it merges, is functionally the same as letting unsigned dependency updates ship straight to production. The declaration was sound. The process around it wasn't.
This is the more dangerous of the three vectors precisely because it scales. A floating branch reference is a single point of exposure in a single file. An automated refresh pipeline reruns that exposure on a schedule, across every input, on every build, without anyone deciding each time whether the new revision is trustworthy. A pipeline configured to run nix flake update on a cron job, or a team that simply never commits flake.lock to version control at all, leaves every downstream build exposed to compromise at evaluation time, before Nix's sandboxing even activates.
That timing detail matters more than it might first appear. Nix's sandbox isolates the build step: once evaluation has produced a derivation, the build runs with no network access and no filesystem access beyond what's declared. But a malicious input that gets pulled in during an unreviewed lock refresh is already inside the derivation before the build starts. It looks legitimate to the sandbox, legitimate to any dependency scanner running after the fact, because by the time those tools look, the compromised revision has already been accepted as the input of record.
The gap is procedural, and the fix is too. Running nix flake update --commit-lock-file turns each refresh into a discrete, reviewable commit rather than a silent rewrite, something a reviewer can read the same way they'd read a diff to package-lock.json or go.sum. Pair that with --pure-eval in CI, which forbids impure operations during evaluation and catches a class of supply chain surprises before they ever become build artifacts.
The third drift vector: silent lock-file drift that goes undetected between refreshes
Even a team that does everything right on the first two vectors, pinning every input to a commit and committing every lock refresh under review, can still accumulate a quieter kind of drift. It shows up when the input declared in flake.nix and the revision recorded in flake.lock start to diverge from what documentation, README files, or team knowledge claim they are. A README might reference a version or a branch that the lock file has since moved past. Nobody wrote a bad declaration and nobody skipped a review. The documentation simply stopped matching the state of the repository, and no standard Nix tooling raises an alert when that happens.
The exposure window here has an indeterminate length, which makes it harder to reason about than the first two vectors. Between the moment a declaration is authored and the moment the next nix flake update runs, the gap between what the project claims and what it actually locks could be days, weeks, or months, depending entirely on how disciplined a team is about running refreshes and checking them against documentation afterward. During that stretch, the exposure exists quietly. Nothing fires to announce it.
Transitive inputs make this worse. A flake that depends on flake-utils, which in turn depends on something else further down the chain, inherits whatever drift accumulates at every link. An attacker who compromises any node in that chain can inject code into the eventual build, and those deeper nodes can drift independently of anything declared at the top level, invisible to someone only reviewing the project's own flake.nix.
A feature request filed as the "driftcheck" proposal names this gap directly: compare the recorded revision at nodes.<input>.locked.rev in flake.lock against whatever version a project's README or documentation claims. It's a feature request, not a shipped tool. Nothing in the current Nix toolchain does this automatically. But the fact that practitioners are asking for it is itself evidence that the gap is real enough to notice, and the comparison it proposes is simple enough that a team can build the check into CI today without waiting for someone else to ship it as a product.
What the content-addressed store closes off, and what it cannot
None of the three drift vectors above are arguments against Nix's underlying design. They're arguments for being precise about where that design's guarantees end. The content-addressed store closes off an entire class of attack that plagues other package ecosystems: silent substitution, where a dependency changes underneath a project without anyone noticing.
The store delivers three things concretely. Builds run hermetically, with no network or filesystem access beyond what's explicitly declared as an input. The store's structure is organized so that tampering with any single component breaks the hash chain all the way up, making corruption detectable. And identical inputs produce byte-for-byte identical outputs: the store path itself changes if anything upstream changes. Silent substitution after a lock is set becomes structurally impossible, not just procedurally discouraged.
It doesn't protect against a commit that's poisoned but legitimately signed and hash-matched: if the malicious code was present when the hash was taken, the hash will match it forever. And it offers no guard at all on fixed-output derivations, the class of builds that fetch something from the internet during the build step itself. The hash check on an FOD verifies only the final output. It says nothing about what happened on the way to producing that output. A network-fetching build step can be compromised mid-build and still pass hash verification cleanly afterward.
The limit runs deeper, at the foundation nixpkgs is bootstrapped from. The bootstrap-tools tarball referenced in nixpkgs, hosted on tarballs.nixos.org and pinned by hash like everything else in the store, is not reproducible in the strict sense that the rest of the system is. It was built once, hashed, and enshrined into nixpkgs as a trusted starting point. For most supply chain threat models, that's an acceptable foundation to build on. For a team working against a nation-state-level threat model, it's a gap that needs its own separate mitigation, and pretending otherwise would undersell what the rest of this piece is trying to establish honestly.
Closing exposure windows: concrete controls that match each vector
Each of the three drift vectors has a control that closes its specific window, and none of the three substitutes for the other two. A defensible posture requires all three applied together, not a single strong control covering one vector while the others stay open.
For floating branch references, the fix is pinning every input declaration to an immutable commit revision inside flake.nix itself, not only in the generated lock file. That way, a declaration change is required before any upstream update can enter a build at all, and that declaration change is something a reviewer actually sees in a diff. This is the same structural fix a security review recommended for PaperWM: replace moving declarations with pinned, reviewed revisions, update them on purpose when a new version is actually needed, and let the resulting lock-file change go through the same review a source-code change would.
For automated lock refreshes, the fix is making every refresh a discrete, attributable commit. nix flake update --commit-lock-file does exactly that, and running it through the same review gate as any other code change means a refresh can't quietly introduce a compromised revision without someone's eyes on the diff first. Adding --pure-eval in CI forbids impure operations during evaluation, catching a class of supply chain surprises before they ever become build artifacts that sandboxing would otherwise treat as legitimate.
For silent drift detection, the fix is instrumentation. CI can parse the JSON structure of flake.lock directly and compare the pinned revisions it finds there against what's declared in flake.nix and what's claimed in documentation. The pattern the driftcheck proposal describes, matching nodes.<input>.locked.rev against README references, is straightforward to implement today inside an existing pipeline, with no need to wait for a dedicated tool to ship.
Teams operating at the high end of this posture go further still. The applicative-systems/secure-supply-chain demonstration builds an offline, source-only closure that captures every source tarball, every Nix expression, and the bootstrap tools themselves, enabling a build to be reproduced entirely offline and audited by a third party with no dependency on live infrastructure at build time. That's what complete closure verification looks like for a regulated environment or a high-security deployment, and it's a useful target even for teams that won't need every piece of it.
Binary cache hardening rounds out the picture. A self-hosted cache, signed with a key held in a hardware security module, with the public cache explicitly excluded from production artifact resolution and require-sigs = true enforced, means production nodes substitute only from a cache the team controls and trusts. Combined with the first three controls, this closes the loop from declaration, through refresh, through drift detection, to the artifact a production system actually pulls down.
Turning a pinned, reviewed closure into an auditable SBOM and deployment hash
The discipline described above produces more than security. A pinned, reviewed flake closure becomes the raw material for a software bill of materials that's more complete than most language-ecosystem tooling can generate on its own, because Nix's closure includes the entire native dependency graph, not just the application-layer packages a typical SBOM tool inspects. A language-specific dependency manifest describes one layer of what actually ends up in a running system.
Nix doesn't natively emit an SBOM in the SPDX or CycloneDX formats that compliance teams expect to see. But it doesn't need to generate the data from scratch, because the derivation graph already is the complete, accurate record of what went into a build. Every input, every transitive dependency, every revision is already present in a form a script can walk and translate into whichever standard format an auditor requires. That translation step is a tooling convenience. The substance behind it, the actual closure of what was built and from what, is the same closure that the controls in the previous section exist to protect. Getting the drift vectors under control is the same work as producing audit evidence, read from two different angles: one closes the exposure window, and the other hands a compliance team the record that the window stayed closed.
Sources
- Flakes — nix.dev documentation
- nix flake lock - Nix Reference Manual
- nix flake update - Nix Reference Manual
- Flakes - Official NixOS Wiki
- Nix security advisory: Privilege escalation via symlink following during FOD output registration - Security - NixOS Discourse
- Input for Supply Chain Security Panel at NixCon - NixCon - NixOS Discourse
- What guarantees do signatures by binary caches give? - Security - NixOS Discourse
- Content-Addressing Store Objects - Nix Reference Manual


