The TLS fork in the road
You're building a Rust app that talks to an API. You add reqwest, run cargo run, and the build fails with a linker error about libssl. Or worse, the build succeeds, you ship the binary to a minimal container, and it crashes because the container doesn't have the right certificates installed. You need TLS, but Rust's ecosystem splits the path into two distinct tracks. One relies on your operating system's C libraries. The other is written entirely in Rust. Picking the wrong one can turn a five-minute task into a dependency nightmare.
The lock and the box
Think of TLS like a secure seal on a package. native-tls is like asking the post office to apply their standard wax seal. It works everywhere the post office operates, and the seal matches what everyone else in town expects. But you have to trust the post office. If the post office is closed, or if you're shipping to a remote village that doesn't recognize their seal, you're stuck. You also have to hope the post office didn't mix up the wax batches.
rustls is like welding a custom titanium seal onto the box yourself. You carry the welding gear. You control the seal. It works anywhere, anytime, and you know exactly how it works because you built it. The trade-off is you have to carry the gear, which adds a tiny bit of weight to your backpack. In Rust terms, that weight is the compiled Rust code, which is usually negligible compared to the pain of managing C dependencies.
The minimal setup
The reqwest crate is the standard way to make HTTP requests in Rust. By default, reqwest enables native-tls on most platforms. This default is a trap for beginners who want portability. You have to explicitly opt out of the default features to switch to rustls.
// Cargo.toml
[dependencies]
// Disable default features to drop native-tls.
// Enable rustls-tls to bring in the pure Rust implementation.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["full"] }
// main.rs
use reqwest;
/// Fetches data from an HTTPS endpoint using rustls.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a client. The TLS backend is determined by Cargo.toml, not here.
let client = reqwest::Client::new();
// Send the request. rustls handles the handshake and verification.
let resp = client.get("https://httpbin.org/get").send().await?;
println!("Status: {}", resp.status());
Ok(())
}
The default-features = false line is the key. Without it, reqwest pulls in native-tls even if you add rustls-tls, and Cargo will reject the manifest with a feature conflict. You must disable the defaults to make room for the alternative.
What happens under the hood
When you compile with rustls, Cargo downloads pure Rust crates. There are no calls to pkg-config. No hunting for headers. No external build tools. The compiler links everything statically into your binary. The result is a single executable that contains the TLS logic, the crypto implementation, and the root certificates. You can copy that binary to any machine with the same architecture and it runs.
When you run the program, rustls performs the TLS handshake using its own crypto primitives. It verifies the server's certificate against a bundled set of root certificates from Mozilla. The process is deterministic. The behavior on Linux matches the behavior on Windows matches the behavior on macOS. There is no "works on my machine" variance caused by different versions of OpenSSL installed on the host.
With native-tls, the compiler generates code that calls into the system library. On Linux and macOS, that's OpenSSL or LibreSSL. On Windows, it's SChannel. The binary depends on those libraries being present at runtime. If you build on a machine with OpenSSL 3.0 and run on a machine with OpenSSL 1.1, you might hit symbol mismatches. If you build on macOS and run on Linux, the binary won't run at all because the system libraries are different.
Root certificates: the hidden detail
Root certificates are the trust anchors. They tell the TLS client which certificate authorities are allowed to sign server certificates. rustls bundles webpki-roots. These are Mozilla's root certificates. They are updated periodically as part of the rustls release cycle. If your organization uses an internal certificate authority, rustls won't trust it by default.
native-tls reads the system store. On Windows, that's the Windows Certificate Store. On macOS, the Keychain. On Linux, files in /etc/ssl/certs or similar paths. If you need to trust a corporate proxy or an internal CA, native-tls often works out of the box because the system admin has already installed the root.
Convention aside: The community convention for reqwest with rustls is to also enable rustls-native-certs. This feature tells rustls to look at the system store for roots, bridging the gap. You get the pure Rust TLS stack but still trust the system's CAs. Most production apps use this combination unless they have a specific reason to rely only on Mozilla's roots.
// Cargo.toml
[dependencies]
// rustls-tls for the stack, rustls-native-certs to trust system roots.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "rustls-native-certs"] }
Realistic example: library features
When you're writing a library, you often want to let the user choose the TLS backend. You can expose optional features in your Cargo.toml and forward them to reqwest.
// Cargo.toml
[package]
name = "my-api-client"
version = "0.1.0"
[features]
// Default to native-tls for maximum compatibility with existing setups.
default = ["native-tls"]
// Users can opt into rustls for portability.
rustls = ["reqwest/rustls-tls"]
// Users can opt into native-tls explicitly.
native-tls = ["reqwest/native-tls"]
[dependencies]
reqwest = { version = "0.12", default-features = false }
// lib.rs
/// Client for the API.
pub struct Client {
// The inner client. The TLS backend is determined by features.
inner: reqwest::Client,
}
impl Client {
/// Create a new client.
pub fn new() -> Self {
Self {
// The client builder respects the features enabled in Cargo.toml.
inner: reqwest::Client::new(),
}
}
/// Fetch a resource.
pub async fn get(&self, url: &str) -> Result<reqwest::Response, reqwest::Error> {
self.inner.get(url).send().await
}
}
This pattern lets downstream users pick the backend that fits their deployment. A CLI tool author might enable rustls to ship a portable binary. A desktop app author might stick with native-tls to share the system certificate store.
Pitfalls and errors
Feature conflicts are the most common mistake. If you enable both rustls-tls and native-tls in the same crate, Cargo rejects the build. The error message mentions conflicting features. Check your Cargo.toml carefully. You can only have one TLS backend active per crate graph.
Cross-compilation breaks native-tls frequently. If you're building on macOS for Linux, you need Linux OpenSSL headers. This requires a cross-compilation toolchain and often a Docker container with the target libraries. rustls compiles to any target that Rust supports. No extra setup. If you're targeting Alpine Linux, native-tls often fails because Alpine uses musl and doesn't include OpenSSL by default. The linker screams cannot find -lssl or cannot find -lcrypto. Switching to rustls fixes this instantly.
Certificate verification failures happen when the root store is stale or missing. If you use rustls without rustls-native-certs and the server uses a root that isn't in Mozilla's bundle, the handshake fails. You'll see a verification error in the response. If you use native-tls on a minimal container, the system roots might not be installed. The error looks similar, but the fix is installing the ca-certificates package. With rustls, the roots are always there.
Decision matrix
Use rustls when you need a pure Rust implementation that compiles without external C libraries or system packages. Use rustls when you are cross-compiling to Alpine Linux, Windows from Linux, or any target where installing OpenSSL headers is a pain. Use rustls when you want consistent TLS behavior across all platforms, since the library brings its own root certificates and crypto implementation. Use rustls when you are building a CLI tool or a server binary that should run anywhere without worrying about the host's TLS stack.
Use native-tls when your application must trust the exact same certificate authority store as the user's operating system, such as for a desktop app that shares roots with the local browser. Use native-tls when you are maintaining a legacy codebase that already depends on the system OpenSSL and you want to avoid linking multiple copies of the library. Use native-tls when you need to support TLS features that are only available in the system library and not yet implemented in rustls, though this case is rare.
Don't fight the linker. Switch to rustls. The ecosystem is moving that way, and the portability wins are real.