How to Minimize Attack Surface in Rust Applications

Reduce Rust security risks by building release binaries without default features and auditing dependencies with cargo audit.

You deploy a Rust service. The borrow checker passed. The tests green. You feel invincible. Then a security scanner flags your binary. It found a vulnerability in a dependency you didn't explicitly add. Or it found debug symbols that leak memory layout. Or the binary is 50MB because a single crate pulled in a full TLS stack you never use. Rust gives you memory safety. It does not give you supply chain safety. It does not give you minimal binaries by default. You have to trim the fat and lock the doors.

Attack surface is the total area where an attacker can touch your system

In a web app, the attack surface includes every HTTP endpoint, every parameter, and every file upload handler. In a binary, the attack surface includes the code that runs, the dependencies that are linked, the metadata that is embedded, and the configuration that is exposed. Rust's type system shrinks the code path surface by preventing many classes of bugs at compile time. Your build process shrinks the dependency and metadata surface by removing unused code and checking for known vulnerabilities.

Think of a fortress. Rust builds the walls with unbreakable stone. But if you leave the gate open, or if you invite in a merchant who carries a plague, the stone walls won't save you. Minimizing attack surface means locking every door you don't use and inspecting every package that enters the gates.

Trim the dependencies with feature flags

Rust crates often enable heavy functionality by default. A crate might enable std, default-tls, serde, or openssl in its default feature set. If you don't need that functionality, you don't need the code. Disabling default features removes the code paths, the dependencies, and the potential vulnerabilities associated with that code.

# Cargo.toml
[dependencies]
# Explicitly disable defaults to avoid pulling in unused code.
# Many crates enable heavy features like "default-tls" or "std" by default.
reqwest = { version = "0.11", default-features = false, features = ["blocking"] }

# If you only need JSON, don't enable the full form/multipart stack.
serde_json = "1.0"

The convention in the Rust community is to use default-features = false for libraries and for binaries where you want strict control. It forces you to think about what you actually need. If a crate requires a feature that you don't understand, that's a signal to investigate. You might be pulling in a crypto library, a network stack, or a serialization format that expands your attack surface without adding value.

Don't assume default features are safe. They are convenient. Convenience and security often pull in opposite directions.

Harden the build artifacts

A release build does more than enable optimizations. It changes the binary in ways that affect security. Debug builds include symbols, assertions, and unwind tables. Release builds strip some of this by default, but not all. You need to configure the compiler to produce a minimal, hardened binary.

# Cargo.toml
[profile.release]
# Optimize for size. This reduces the binary footprint and can eliminate
# dead code more aggressively than opt-level = 3.
opt-level = "z"

# Link-time optimization merges functions across crate boundaries.
# This allows the compiler to remove unused functions from dependencies.
lto = true

# Strip debug symbols. This removes the symbol table and DWARF info.
# Attackers can't use symbols to reverse-engineer your logic.
strip = true

# Abort on panic instead of unwinding. This shrinks the binary and
# removes the unwind table, which can leak stack information.
panic = "abort"

The opt-level = "z" setting tells the compiler to optimize for size. This can reduce the binary by 20-40% compared to opt-level = 3. A smaller binary has less code to attack. The lto = true setting enables link-time optimization. This allows the compiler to see across crate boundaries and remove functions that are never called. This is dead code elimination on steroids. It reduces the code that runs, which reduces the code that can be attacked.

The strip = true setting removes debug symbols. This is critical for production binaries. Debug symbols map memory addresses to function names and source lines. If an attacker gets a crash dump or a memory leak, symbols make it trivial to understand what happened. Stripping symbols forces the attacker to reverse-engineer the binary from scratch.

The panic = "abort" setting changes how panics are handled. The default is unwind, which walks the stack and drops values. This requires unwind tables and adds code size. abort terminates the process immediately. This is safer in many cases because it avoids running destructor code that might be in an inconsistent state. It also shrinks the binary.

Configure your release profile to minimize the binary. A smaller binary is a harder target.

Audit the supply chain

Rust's package manager, Cargo, resolves dependencies automatically. This is convenient. It also means you might be shipping code you didn't review. cargo audit checks your dependency tree against the RustSec Advisory Database. It flags known vulnerabilities in your dependencies.

# Install cargo-audit if not present
cargo install cargo-audit

# Check for known vulnerabilities in the dependency tree
cargo audit

# If vulnerabilities are found, cargo audit suggests fixes.
# Apply the fixes and re-run until clean.
cargo update
cargo audit

cargo audit reads your Cargo.lock file. It compares every dependency version against the database of known advisories. If a dependency has a vulnerability, cargo audit reports it with a severity rating and a link to the advisory. You can then update the dependency or pin to a safe version.

The convention is to run cargo audit in continuous integration. Every commit should pass cargo audit. This ensures you don't accidentally introduce a vulnerable dependency. If cargo audit fails, the build fails. This treats supply chain security as a gatekeeper, not an afterthought.

cargo audit is a snapshot. It checks for known vulnerabilities. It does not check for logic bugs, side-channel leaks, or configuration errors. It is a necessary tool, but not a sufficient one.

Treat cargo audit as a gatekeeper. If it fails, you don't ship.

Realistic example: A hardened service

A production Rust service needs careful configuration. The Cargo.toml should disable default features, enable only what is needed, and configure a hardened release profile. The build should run cargo audit and produce a stripped, LTO-optimized binary.

# Cargo.toml
[package]
name = "secure-service"
version = "0.1.0"
edition = "2021"

[dependencies]
# Disable defaults. Enable only the features you need.
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] }
serde = { version = "1", default-features = false, features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.11", default-features = false, features = ["blocking", "json"] }

[profile.release]
opt-level = "z"
lto = true
strip = true
panic = "abort"
# Codegen units = 1 improves LTO effectiveness.
# It forces the compiler to process the entire crate as a single unit.
codegen-units = 1
# CI script snippet
# Install audit tool
cargo install cargo-audit

# Check for vulnerabilities
cargo audit

# Build the hardened binary
cargo build --release --no-default-features

# Verify the binary is stripped
file target/release/secure-service
# Output should show "not stripped" is absent.

The codegen-units = 1 setting improves LTO effectiveness. By default, Cargo splits the compilation into multiple units for parallelism. This can prevent LTO from seeing the whole picture. Setting codegen-units = 1 forces the compiler to process the entire crate as a single unit. This makes LTO more effective at removing dead code. It also improves optimization quality. The trade-off is slower build times. For production builds, this is worth it.

The --no-default-features flag on cargo build ensures that even if you forgot to disable defaults in Cargo.toml, the build won't enable them. This is a safety net. It forces you to be explicit about features.

Run cargo audit before every build. A vulnerable dependency is a hole in your fortress walls.

Pitfalls and compiler errors

Minimizing attack surface requires attention to detail. Common pitfalls include ignoring unsafe blocks, assuming --release fixes logic bugs, and trusting cargo audit to catch everything.

unsafe blocks bypass Rust's safety checks. Every unsafe block is a potential hole. If you use unsafe, you must audit it carefully. The compiler won't catch logic errors in unsafe code. If you dereference a raw pointer incorrectly, the compiler might not warn you. You might get undefined behavior, which can lead to memory corruption, crashes, or arbitrary code execution.

// src/lib.rs
/// Dereference a raw pointer.
/// SAFETY: The pointer must be valid and aligned.
pub unsafe fn get_value(ptr: *const i32) -> i32 {
    // If the pointer is invalid, this is undefined behavior.
    // The compiler cannot check this.
    *ptr
}

If you try to dereference a raw pointer in safe code, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This is a good thing. It forces you to mark the code as unsafe and acknowledge the risk. Treat every unsafe block as a liability. Minimize the number of unsafe blocks. Isolate them in small helper functions. Document the safety invariants clearly.

cargo audit might miss vulnerabilities that haven't been reported yet. It also might not catch vulnerabilities in your own code. It checks the supply chain, not your logic. You need to review your code, run fuzz tests, and use tools like cargo clippy with security lints.

# Enable clippy security lints
cargo clippy -- -W clippy::security

clippy::security includes lints for common security issues, like using weak random number generators, ignoring errors, or using deprecated functions. These lints can catch issues that cargo audit won't.

Don't assume --release fixes logic bugs. Release mode enables optimizations. It does not fix incorrect logic. If your code has a race condition, a buffer overflow in unsafe code, or a logic error, release mode won't help. It might even hide the bug by changing timing behavior.

Trust the borrow checker, but verify the supply chain.

Decision: when to use these tools

Use default-features = false when you want strict control over dependencies and want to avoid pulling in unused code. Use default-features = false when a crate enables heavy features like std, default-tls, or openssl by default and you don't need them. Use default-features = false when you are building a library and want to minimize the attack surface for your users.

Use cargo audit when you need to check for known vulnerabilities in your dependency tree. Use cargo audit in continuous integration to ensure you don't ship vulnerable dependencies. Use cargo audit when you update dependencies and want to verify the new versions are safe.

Use cargo deny when you need more advanced supply chain checks, including license compliance and source verification. Use cargo deny when you want to enforce policies on dependency versions, sources, and licenses. Use cargo deny when cargo audit is not sufficient for your security requirements.

Use strip = true when you are building a production binary and want to remove debug symbols. Use strip = true when you want to reduce the binary size and prevent attackers from reverse-engineering your logic. Use strip = true when you are deploying to environments where debug symbols are not needed.

Use lto = true when you want to reduce the binary size and eliminate dead code across crate boundaries. Use lto = true when you are building a release binary and want maximum optimization. Use lto = true when you are deploying to resource-constrained environments where binary size matters.

Use panic = "abort" when you want to shrink the binary and remove unwind tables. Use panic = "abort" when you are building a service where panics should terminate the process immediately. Use panic = "abort" when you want to avoid running destructor code that might be in an inconsistent state.

A smaller binary is a harder target. Lock every door you don't use.

Where to go next