Open Source Security

Rust Feature Flags: Supply Chain Implications

Cargo feature flags look like a compilation convenience but they are a load-bearing piece of your supply chain posture. Here is why.

Nayan Dey
Senior Security Engineer
7 min read

Most Rust developers treat Cargo features as a compilation-ergonomics feature: "turn on tls, turn off default-features, keep the binary small." They are that, but they are also a supply chain decision that most teams do not realize they are making. A feature flag can double the size of your transitive dependency graph, pull in a crate with a different license, or expose you to an advisory that would not otherwise apply. After a few years of looking at Rust projects professionally, I now treat feature flag changes the same way I treat direct dependency additions: they need review.

The Mechanic

A feature in Cargo.toml is a named set of optional dependencies and conditional compilation flags. When a downstream crate declares foo = { version = "1.2", features = ["bar"] }, the bar feature may pull in additional dependencies, enable new code paths in foo, or both. Features can also be default-on, meaning adding a dependency with no feature configuration still enables the baseline set.

The concrete implication: the same version of the same crate can have very different dependency graphs and very different attack surfaces depending on which features you ask for. reqwest 0.12.5 with default-features = true pulls in roughly thirty more crates than reqwest 0.12.5 with default-features = false, features = ["rustls-tls"]. The first graph includes native-tls, openssl-sys, and a handful of others that the second graph does not. If an advisory lands against one of those crates, you are affected or not affected depending on your feature configuration, and cargo-audit will tell you the truth about that as long as you are honest with it.

Feature Unification and Surprise Expansion

Cargo does feature unification across a workspace. If crate A depends on tokio with feature "rt" and crate B depends on tokio with feature "rt-multi-thread", your workspace compiles tokio with both features enabled. This is documented and, for the most part, desirable; it avoids compiling the same crate multiple times with different configurations.

The security implication is less obvious. A crate deep in your transitive graph can enable a feature in a crate closer to you, and you will compile the union. I have seen cases where a single developer-tools dependency, added to Cargo.toml for a quick experiment, flipped on rustls's dangerous_configuration feature (which exposes APIs for accepting invalid certificates) across the entire workspace because the tool's author needed it and Cargo unified features. The main binary never called the dangerous API, but the API was compiled in and anyone who could get code into the project could use it.

In Cargo 1.51 (2021), resolver = "2" was stabilized and made the default in edition-2021 crates, which meaningfully reduced feature-over-unification between build and host targets. It did not eliminate the within-target unification I described. The 2024 edition, which went stable in the fall of 2024, continues resolver v2 semantics.

Concrete 2024 Example

In April 2024 a team I was consulting with had a finding from cargo-audit against shlex 1.3.0 (RUSTSEC-2024-0006, a parsing issue). They were confused because they did not use shlex directly. Tracing the graph, shlex was pulled in by bindgen 0.69.4, which was pulled in by a -sys crate, which was enabled only when a specific feature was turned on in a dependency three levels up, which the team had enabled by accident when they added a stats crate they thought was lightweight.

The fix was to disable the feature. Zero code changes in their application, no dependency updates, the advisory simply no longer applied because the affected crate was no longer in the graph. Features changed the supply chain answer.

Default Features as a Footgun

The default features pattern is ergonomic for library authors and treacherous for consumers. When you cargo add tokio, you get the default features. For tokio 1.40, that is a reasonably sensible set, but for less-maintained crates it can be anything. I have seen crates where the default features include an experimental or legacy feature that the maintainer forgot to remove after a v1 release; consumers who do not explicitly disable defaults quietly ship the experimental code into production.

My policy in new projects is: default-features = false on every dependency, with the specific features needed listed explicitly. It is more verbose but it makes the supply chain decisions legible in PR diffs. When someone adds a feature, they have to add it in Cargo.toml, which means a reviewer sees it.

Feature-Gated unsafe and unstable

A specific pattern worth calling out: some crates gate unsafe blocks or nightly-only APIs behind non-default features. bytes has historically had features that enable zero-cost but less-validated paths. serde_json has a preserve_order feature that changes the underlying data structure and its invariants. Enabling a feature can change the soundness properties of a crate.

The discipline I encourage is: whenever you enable a non-default feature, read the feature's documentation and check whether it enables unsafe or trades a safety property for performance. If it does, document the decision in Cargo.toml or a DECISIONS file. Future you, or your successor, will thank you.

Feature Flags and Licenses

Features can also change the license surface. ring has a feature that pulls in BoringSSL-derived code with a different license profile. Some cryptography crates have GPL-licensed feature-gated dependencies that they do not pull in by default. cargo-deny with the license check enabled can tell you what you are actually compiling, which will be different from what you would see if you just looked at the top-level crate's license.

A team I worked with in mid-2024 discovered through a release-gate license scan that a transitive feature-gated dependency had pulled in a crate under LGPL-3.0. Their legal posture required MIT or Apache-2.0 only. The feature in question had been enabled for a debugging path that never ran in production, but the compiled binary still contained the LGPL code. The remediation was to remove the feature. The lesson was that feature flags are license decisions.

Recommendations

Always write default-features = false and explicitly list the features you use. Review Cargo.toml feature changes in PRs the same way you review dependency additions. Run cargo-tree with --edges features periodically to see what your feature graph actually looks like; it is usually more than you think. Pair cargo-audit with cargo-tree so when an advisory lands you can answer "does it affect us with our feature configuration" quickly. When you enable a non-default feature, leave a comment explaining why, because the person who removes it in 2027 will need to know.

For library authors the corresponding advice is: do not enable heavy features by default, document what each feature actually pulls in, and version features the same way you version APIs. Adding a default feature in a minor release is a breaking change for anyone doing supply chain review.

How Safeguard Helps

Safeguard models your Cargo feature configuration as part of the dependency graph rather than treating versions in isolation, so you see the actual compiled graph, not the optimistic one. When a RUSTSEC advisory lands we tell you whether your enabled features pull the affected code path, which is the difference between a noisy alert and a real finding. We track feature drift across releases so a default-feature addition in a minor version bump shows up as a policy event, not silent graph growth, and flag feature combinations that trade safety properties or change license posture before they land in your release.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.