When the system library is missing
You are shipping a Rust binary to a production server. The server runs a minimal Alpine Linux container. It has no package manager. It has no libssl. Your binary panics on startup because it cannot find the TLS library. This happens when your application relies on system dependencies for cryptography. The panic is not a bug in your code. It is a missing shared library on the host.
You could add a Docker layer to install OpenSSL. You could document the dependency for every user. Or you can change how you handle TLS. Rust offers a path where the TLS implementation lives inside your binary. Pure Rust. No C code. No dynamic linking. No missing libraries. This is the promise of rustls.
Pure Rust TLS
TLS protects data in transit. It encrypts the pipe between client and server. It also authenticates the server using certificates. A certificate is a digital identity document signed by a trusted authority. Your application needs a list of trusted authorities. This list is the root store.
In the early days of Rust, applications often delegated TLS to OpenSSL. OpenSSL is written in C. It requires Foreign Function Interface calls to use from Rust. FFI breaks memory safety guarantees. It makes cross-compilation harder. It ties your binary to the host system's library versions. rustls replaces all of that. It implements the TLS protocol entirely in Rust. It uses webpki for certificate validation. Both are pure Rust. You get the same security guarantees as the rest of your code. You get static linking. You get a binary that runs anywhere Rust runs.
Think of OpenSSL like a specialized vendor who speaks a different language and works in a separate room. You have to write down requests, pass them through a window, and hope the translation is correct. rustls is like having a team member who speaks your language and sits at your desk. The communication is direct. The types match. The compiler checks the logic.
The minimal setup
The most common way to use TLS in Rust is through the reqwest HTTP client. reqwest supports multiple TLS backends. The default backend on many platforms is native-tls, which wraps the system's TLS library. To use rustls, you must explicitly opt in and disable the defaults.
Add reqwest to your Cargo.toml. Disable default features to prevent native-tls from sneaking in. Enable the rustls-tls feature.
[dependencies]
# Disable defaults to avoid native-tls. Enable rustls-tls for pure Rust TLS.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["full"] }
The convention in the Rust community is to always set default-features = false when specifying TLS features. This prevents accidental inclusion of the wrong backend. If you forget, cargo tree will show native-tls in your dependency graph, and your binary will still link against OpenSSL.
Here is a minimal async client that makes an HTTPS request.
use reqwest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a client. It uses rustls because of the Cargo.toml configuration.
let client = reqwest::Client::new();
// Send a GET request. rustls handles the handshake and encryption.
let resp = client.get("https://httpbin.org/get").send().await?;
println!("Status: {}", resp.status());
println!("Body: {}", resp.text().await?);
Ok(())
}
This code compiles to a single binary. It contains the TLS implementation. It contains the root certificates. It runs on any platform without external dependencies.
What happens under the hood
When you compile this code, rustls becomes part of your object file. The linker bundles the TLS logic into your executable. There is no search for libssl.so at runtime. The binary is self-contained.
At runtime, the handshake follows the standard TLS protocol. rustls sends a ClientHello message. It advertises supported cipher suites and extensions. The server responds with its certificate chain. rustls validates the chain against the root store.
The root store comes from the rustls-native-certs feature if you enabled it, or from bundled roots if you are using webpki-roots. reqwest with rustls-tls typically includes a bundled set of Mozilla root certificates. This ensures your binary works even on systems with no root store installed.
If validation passes, keys are exchanged. Data flows encrypted. If validation fails, rustls returns an error. There are no silent failures. There is no undefined behavior. The error tells you exactly what went wrong. Certificate expired. Hostname mismatch. Untrusted root.
Trust the pure Rust stack. It compiles everywhere, and it validates everything.
Custom roots and client certificates
Real-world applications often need more than the default setup. You might talk to an internal service with a self-signed certificate. You might need mutual TLS where the server authenticates the client. reqwest exposes rustls configuration through builder methods.
Loading a custom root certificate is straightforward. Read the PEM file. Parse it. Add it to the client builder.
use reqwest::{ClientBuilder, Certificate};
use std::fs;
fn build_client_with_root() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
// Read the custom root certificate from disk.
let root_pem = fs::read("internal-root.pem")?;
// Parse the PEM data into a Certificate object.
// This validates the PEM format but does not check trust yet.
let root_cert = Certificate::from_pem(&root_pem)?;
// Build the client with the custom root added to the trust store.
let client = ClientBuilder::new()
.add_root_certificate(root_cert)
.build()?;
Ok(client)
}
The Certificate::from_pem method parses the PEM encoding. It returns a reqwest::Certificate which wraps the underlying rustls types. You can add multiple roots. The client will trust any certificate signed by any of the roots in the store.
For mutual TLS, you need to provide a client identity. This includes the client certificate and the private key.
use reqwest::Identity;
fn build_client_with_identity() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
let identity_pem = fs::read("client-cert-and-key.pem")?;
// Parse the identity. This expects a PEM file containing the cert and key.
let identity = Identity::from_pem(&identity_pem)?;
let client = ClientBuilder::new()
.identity(identity)
.build()?;
Ok(client)
}
The Identity::from_pem method handles the parsing. It extracts the certificate and the private key. rustls uses the key during the handshake to prove the client's identity.
Bundle your roots. Don't rely on the system store for production binaries. The system store varies by OS and installation. Bundled roots give you deterministic behavior.
Pitfalls and configuration traps
TLS configuration has sharp edges. The most common trap is mixing backends. If you have reqwest with rustls-tls and another crate pulls in reqwest with native-tls, you might get duplicate symbols or confusion. Use cargo tree to check your dependency graph. Ensure only one TLS backend is active.
Another trap is losing the system roots. When you disable default features, you might strip out the system root loading logic. reqwest with rustls-tls includes bundled roots by default, but if you want to use the OS trust store, you need the rustls-native-certs feature.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "rustls-native-certs"] }
Without rustls-native-certs, the client relies solely on bundled roots. This is safer for static binaries, but it means you miss OS-level trust updates. You must update your Rust dependencies to get new roots. With rustls-native-certs, the client loads roots from the OS. This keeps you up to date, but it reintroduces a dependency on the OS trust store.
Compiler errors can also trip you up. If you pass raw bytes to a method expecting a Certificate, you get E0308 (mismatched types). If you forget to implement a trait for a custom type, you get E0277 (trait bound not satisfied). The compiler messages are precise. Read them. They tell you exactly what type is expected.
Certificate validation errors appear at runtime. reqwest returns a reqwest::Error. You can check if the error is related to TLS using error.is_connect() or by inspecting the source. rustls errors are descriptive. They tell you if the certificate is expired, if the hostname does not match, or if the root is untrusted.
Keep the unsafe surface zero. rustls gives you that. If you see unsafe in your TLS code, you are doing something wrong.
Choosing your TLS backend
Rust offers several ways to handle TLS. The right choice depends on your deployment target and your requirements.
Use reqwest with rustls-tls when you want a pure Rust stack, static binaries, or easy cross-compilation. This is the default choice for most new Rust projects. It eliminates C dependencies and ensures your binary runs anywhere.
Use reqwest with native-tls when you are building a desktop application and want to use the OS keychain or system trust store seamlessly without bundling roots. This reduces binary size slightly and integrates with OS security features, but it ties your binary to the host system.
Use ureq when you need a synchronous HTTP client with minimal dependencies. ureq supports rustls and is simpler than reqwest. It is ideal for scripts or tools where async is overkill.
Use the openssl crate when you must interoperate with legacy C code that requires OpenSSL handles. This is rare. Most modern Rust code should avoid this path unless you are wrapping an existing C library.
Reach for rustls. It is the standard for modern Rust applications. It is safe, fast, and portable.