The build that refuses to link
You finish a Rust service that fetches data from an API. It runs perfectly on your laptop. You push it to CI, spin up a minimal Alpine container, and watch the build fail. The linker cannot find libssl.so. You add apt install libssl-dev to your Dockerfile, and the image balloons from 15 megabytes to 80. Your developers start complaining about version mismatches on macOS. You just hit the OpenSSL wall.
TLS is the lock icon in your browser. It encrypts traffic, verifies identities, and prevents man-in-the-middle attacks. Every networked application eventually needs it. The traditional path in many ecosystems is to bind to OpenSSL. OpenSSL is mature and ubiquitous. It is also a sprawling C codebase that demands a system compiler, specific header files, and a working linker on every machine that touches your project.
rustls removes that dependency entirely. It implements the TLS protocol in pure Rust. The cryptographic primitives come from a separate crate, but the protocol state machine, certificate validation, and record layer are all Rust. You run cargo build, and it just works. No system packages. No linker errors. No version drift between your local machine and your production container.
TLS without the C baggage
Think of OpenSSL like a legacy shipping company. It handles everything from freight to customs, but you need to speak three languages, fill out paper forms, and wait for a dispatcher to approve your route. rustls is a modern courier service that operates entirely within one country. The paperwork is digital, the routes are pre-approved, and the entire chain runs on a single operating system.
The trade-off is scope. rustls deliberately supports only modern TLS versions and secure cipher suites. It will not negotiate SSLv3. It will not use RC4. It will not accept certificates with weak signatures. This conservatism is a feature, not a bug. You get security by default, and you avoid the configuration mistakes that plague OpenSSL deployments.
The crate separates the protocol from the cryptography. rustls handles the handshake, record framing, and state transitions. The math happens in a backend crate. You pick ring for simplicity and broad compatibility, or aws-lc-rs if your organization requires FIPS 140-3 validation. The feature flags in Cargo.toml control which backend gets compiled in.
The one-line swap in reqwest
Most developers meet rustls through a higher-level HTTP client. reqwest is the standard choice for async HTTP in Rust. By default, it uses native-tls, which delegates to the operating system's TLS library. On Linux, that means OpenSSL. On Windows, it means SChannel. You can swap the backend without changing a single line of application code.
Here is the dependency configuration that triggers the swap.
# Cargo.toml
[dependencies]
# Disabling default features removes the OpenSSL/native-tls dependency.
# We re-enable only the rustls-tls feature and json parsing.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["full"] }
The application code remains identical to a standard reqwest example. The TLS handshake happens behind the scenes, but the implementation is now pure Rust.
// A standard HTTP GET request. The TLS layer is handled entirely by rustls.
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// reqwest delegates to rustls for the handshake and encryption.
let body = reqwest::get("https://api.github.com/zen")
.await?
.text()
.await?;
// Print the plaintext response to verify the connection succeeded.
println!("{body}");
Ok(())
}
The benefit shows up at compile time and in your deployment artifacts. A clean Alpine image compiles this without installing any C libraries. Your Docker layers shrink. Your CI pipelines stop failing on missing headers. Your team stops arguing about OpenSSL versions.
Trust the feature flag. It does the heavy lifting for you.
Wiring it up by hand
Higher-level clients hide the mechanics. When you build a custom protocol, talk to a database driver, or write a low-level proxy, you need to interact with rustls directly. The pattern is consistent: build a configuration object, load a trust store, open a TCP socket, and wrap it in a TLS connection.
First, set up the dependencies. You need the core crate and a root certificate bundle.
# Cargo.toml
[dependencies]
rustls = "0.23"
# webpki-roots ships Mozilla's CA bundle as compiled-in Rust data.
# This avoids reading system certificate files at runtime.
webpki-roots = "0.26"
Next, construct the client configuration. This object defines cipher suites, protocol versions, and which certificates you trust.
use std::sync::Arc;
use rustls::{ClientConfig, RootCertStore};
fn build_config() -> Arc<ClientConfig> {
// Start with an empty trust store. rustls rejects any certificate
// that does not chain up to a root in this collection.
let mut roots = RootCertStore::empty();
// Inject Mozilla's public root certificates.
// This is the standard convention for standalone Rust applications.
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
// Build the config. with_no_client_auth means we are not presenting
// a client certificate to the server. We are just verifying them.
let config = ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
// Wrap in Arc so multiple connections can share the config
// without paying the cost of rebuilding it.
Arc::new(config)
}
Now connect to a server and wrap the raw socket. rustls is sans-IO by design. It does not know about TCP, UDP, or files. It only knows how to encrypt and decrypt byte buffers. You provide the transport.
use std::io::{Read, Write};
use std::net::TcpStream;
use rustls::{ClientConnection, Stream};
fn connect_and_read() -> Result<(), Box<dyn std::error::Error>> {
// Open a plain TCP connection to port 443.
let mut sock = TcpStream::connect("example.com:443")?;
// Validate the hostname string. rustls rejects IP addresses
// unless you explicitly opt into IP validation.
let server_name = "example.com".try_into()?;
// Create the TLS state machine. This triggers the handshake.
let config = build_config();
let mut conn = ClientConnection::new(config, server_name)?;
// Stream bridges the TLS state machine and the TCP socket.
// It implements Read and Write, so you can use it like any file.
let mut tls = Stream::new(&mut conn, &mut sock);
// Send a minimal HTTP/1.1 request through the encrypted channel.
tls.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")?;
// Read the server's response into a buffer.
let mut response = Vec::new();
tls.read_to_end(&mut response)?;
println!("{}", String::from_utf8_lossy(&response));
Ok(())
}
Notice the Stream wrapper. It handles the back-and-forth of TLS records. When you call write, Stream encrypts the data, fragments it into TLS records, and pushes it to the socket. When you call read, it pulls raw bytes from the socket, reassembles records, decrypts them, and feeds them to your buffer. You never touch the record format. You just read and write plaintext.
Convention note: developers often reach for rustls-native-certs instead of webpki-roots when building desktop applications or enterprise tools. It reads the OS trust store at runtime, so corporate root CAs installed by IT automatically work. Pick the bundle that matches your deployment target.
Keep the Arc around. Rebuilding the configuration for every connection wastes CPU cycles and allocates memory unnecessarily.
Running a TLS server
The server side follows the same shape. You load a certificate chain and a private key, build a ServerConfig, and wrap incoming TCP streams.
use std::fs::File;
use std::io::BufReader;
use std::net::TcpListener;
use std::sync::Arc;
use rustls::{ServerConfig, ServerConnection, Stream};
use rustls_pemfile::{certs, pkcs8_private_keys};
fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Parse the certificate chain from a PEM file.
// This usually contains the leaf cert plus any intermediates.
let cert_chain = certs(&mut BufReader::new(File::open("server.crt")?))
.collect::<Result<Vec<_>, _>>()?;
// Parse the private key. rustls expects PKCS8 or SEC1 format.
let mut keys = pkcs8_private_keys(&mut BufReader::new(File::open("server.key")?))
.collect::<Result<Vec<_>, _>>()?;
// Build the server config. with_no_client_auth means we do not
// require the client to present a certificate.
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, keys.remove(0).into())?;
let config = Arc::new(config);
// Bind to a port and accept incoming connections.
let listener = TcpListener::bind("0.0.0.0:8443")?;
for stream in listener.incoming() {
let mut sock = stream?;
let mut conn = ServerConnection::new(config.clone())?;
let mut tls = Stream::new(&mut conn, &mut sock);
// Read the client request and send a plaintext response.
let mut buf = [0u8; 1024];
let _ = std::io::Read::read(&mut tls, &mut buf)?;
std::io::Write::write_all(&mut tls, b"HTTP/1.1 200 OK\r\n\r\nhello")?;
}
Ok(())
}
Production servers wrap this loop in an async runtime. tokio-rustls provides the async adapters that let you use rustls with tokio::net::TcpStream. Frameworks like axum and hyper handle the wrapping automatically. You rarely write the Stream boilerplate yourself unless you are building a custom protocol.
Treat the PEM loading as a startup cost. Parse once, cache the Arc, and reuse it for every connection.
Where things go sideways
TLS is a negotiation protocol. Both sides must agree on versions, ciphers, and identities. When they do not, rustls fails fast and tells you exactly why.
The server requires legacy crypto. rustls only supports TLS 1.2 and TLS 1.3 with AEAD cipher suites. If you connect to a banking API from 2008 that demands TLS 1.0 or RC4, the handshake aborts. You will see a NoCiphersuiteMatch error. The fix is not to patch rustls. The fix is to upgrade the server or fall back to native-tls for that specific client.
The certificate chain is incomplete. Servers often forget to send intermediate certificates. rustls validates the full chain. If the intermediate is missing, you get InvalidCertificate(ChainVerificationFailure). The server administrator needs to bundle the intermediates with the leaf certificate. You cannot work around this on the client side.
The hostname does not match. rustls checks the certificate's Subject Alternative Names against the string you pass to ClientConnection::new. If you pass an IP address but the cert only lists a domain, you get InvalidCertificate(NotValidForName). The try_into() call on the server name enforces strict DNS parsing. It catches typos before the handshake even starts.
You forgot to load root certificates. An empty RootCertStore means zero trust. Every connection fails with InvalidCertificate(UnknownIssuer). This is the most common first-run error. Always extend the store with webpki_roots or rustls-native-certs before building the config.
Convention note: rustls feature flags control the crypto backend. If you do not specify one, it defaults to ring. If your build suddenly fails with missing assembly files on an exotic architecture, check your feature flags. aws-lc-rs uses C under the hood but provides a Rust-safe wrapper. Pick the backend that matches your compliance requirements, not your curiosity.
Read the error message. It names the exact validation step that failed. Do not guess.
Picking your TLS backend
Use rustls when you want reproducible builds, smaller container images, or static binaries. It removes the OpenSSL dependency chain and enforces modern security defaults. It is the recommended baseline for new Rust projects.
Use native-tls when your application must honor the operating system's certificate trust store. Corporate environments often install internal root CAs that webpki-roots does not include. native-tls picks them up automatically.
Use OpenSSL bindings directly only when you require a specific legacy cipher, a custom extension, or a feature that rustls explicitly declined to implement. The maintenance cost and build complexity rarely justify the trade-off.
Stick to the pure Rust stack unless you have a documented reason to leave it.