Rustls vs OpenSSL for Backend TLS in 2023
TL;DR — Default to rustls for new Rust services in 2023: simpler builds, statically-linkable, no surprise CVE rollouts / Keep OpenSSL when you need FIPS 140-2, when your enterprise crypto policy demands it, or when interop with legacy TLS quirks matters / The performance gap is small for most backend workloads and not the deciding factor
The TLS question comes up on every Rust service I’ve worked on. The default choice — whatever your dependencies pull in — is usually fine, but “usually fine” hides a real decision that affects build complexity, binary size, deployment shape, and compliance posture. I’ve shipped both. This post is what I’ve learned about when each is the right call.
The context for this post is the production HTTP stack from my axum post — what’s underneath axum and reqwest at the TLS layer. If you haven’t been forced to think about this, you’ve probably been using rustls by accident, which is fine; it’s a good default. The point of choosing consciously is so you don’t get surprised.
The two paths
rustls. A TLS implementation written in Rust. Uses the ring crate (or aws-lc-rs as of 2023) for cryptographic primitives. Pure Rust + Rust-friendly C bindings. Static linking is the natural shape. No system OpenSSL needed at build or runtime.
OpenSSL / native-tls. Bindings to system OpenSSL (or LibreSSL, or BoringSSL via SChannel on Windows / SecureTransport on macOS through native-tls). The C library does the TLS work; Rust crates wrap it. Dynamic linking by default; static linking is possible but painful.
Most ecosystem crates expose feature flags to pick:
# reqwest with rustls
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json", "gzip"] }
# reqwest with native-tls (OpenSSL on Linux, SecureTransport on macOS)
reqwest = { version = "0.11", default-features = false, features = ["native-tls", "json"] }
# sqlx with rustls
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-rustls"] }
# sqlx with native-tls
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls"] }
A trap: if you don’t disable default features, you can end up with both, doubling your binary and depending on system OpenSSL when you thought you didn’t.
The case for rustls
Static binaries are dramatically simpler. A cargo build --release --target x86_64-unknown-linux-musl of a rustls-based service produces a single file you can scp anywhere, including alpine and distroless container images. The OpenSSL path requires either statically linking openssl (the vendored feature, which compiles OpenSSL from source as part of your build) or shipping a base image with the right OpenSSL version. Both work, both have annoyances.
Memory safety. OpenSSL has had a long parade of CVEs — Heartbleed, the BIO_new_NDEF stack overflow in 2022, the 2022 RFC2253 buffer overrun. Most were memory bugs. rustls is written in safe Rust on top of ring, which has a small unsafe core that’s heavily audited. The attack surface is meaningfully smaller, and the bug class is different. This isn’t a guarantee of no vulnerabilities, but the historical rate is lower.
Build complexity. The rustls build is cargo build. The OpenSSL build needs pkg-config, libssl-dev, and on macOS the right OPENSSL_DIR env var if you’re not on a default install. CI configs end up with shims for each platform. With rustls, the build is the same on every host.
Cross-compilation is materially easier. Building for aarch64-unknown-linux-musl from an x86_64 host with rustls is one command. With OpenSSL you’re cross-compiling C as well.
Deterministic builds. No system-library version drift, no “works on my laptop, breaks in CI.”
Standards-conformant. rustls 0.21 (released April 2023) ships TLS 1.2 and TLS 1.3, with TLS 1.3 enabled by default. It deliberately doesn’t support older versions or weak ciphers. If your deployment policy says “TLS 1.2+, no RC4, no CBC,” rustls enforces that by construction — there’s no config switch that would weaken it past a sensible floor.
The case for OpenSSL
FIPS 140-2 / FIPS 140-3. If you sell into US federal, healthcare, or financial sectors that require FIPS-validated cryptography, OpenSSL’s FIPS module is the well-trodden path. As of mid-2023, rustls has FIPS work in progress via aws-lc-rs (AWS’s FIPS-validated fork of ring’s primitives) but it’s not the default story. If FIPS is a hard requirement, OpenSSL is still the boring choice.
Enterprise policy. Some orgs have crypto review processes built around OpenSSL — approved configs, monitoring tools, key management integrations. Going off that path is a procurement and security review exercise that may not be worth fighting.
Specific TLS quirks. Some legacy TLS endpoints negotiate poorly with rustls because they assume OpenSSL extensions or behaviors. Most of these are pre-TLS-1.2 servers that should be replaced anyway, but you don’t always get to choose. If you’re integrating with a partner who can’t be upgraded, native-tls may interop better.
PKCS#11 / HSM integration. OpenSSL has mature engine and provider support for hardware security modules. rustls doesn’t have the same depth here in 2023.
Familiarity. Your security team probably knows how to operate OpenSSL. Audits, key rotations, cert chain debugging — there’s a decade of accumulated knowledge. Don’t underestimate the value of “we already know how to debug this.”
Performance, briefly
A few benchmarks circulated in 2022-2023 had rustls and OpenSSL within 10% of each other on common workloads, with the winner depending on cipher suite, key type, and platform. For backend services handling thousands of RPS, neither is going to be your bottleneck — the database, the upstream API, or the serialization will dominate. Don’t pick TLS on throughput unless you’ve measured your own service.
Handshake performance with TLS 1.3 plus session resumption is comparable. Both implementations support 0-RTT (though most servers configure it conservatively).
One real difference: rustls 0.21 supports aws-lc-rs as the crypto provider, which AWS has been pushing for FIPS support and which uses BoringSSL primitives. Whether that matters depends on your provider choice.
A typical rustls server config
For a server accepting mTLS or standard TLS, the rustls 0.21 setup looks like this:
use rustls::{Certificate, PrivateKey, ServerConfig};
use rustls_pemfile::{certs, pkcs8_private_keys};
use std::sync::Arc;
use std::fs::File;
use std::io::BufReader;
fn load_certs(path: &str) -> anyhow::Result<Vec<Certificate>> {
let mut reader = BufReader::new(File::open(path)?);
let certs = certs(&mut reader)?
.into_iter()
.map(Certificate)
.collect();
Ok(certs)
}
fn load_key(path: &str) -> anyhow::Result<PrivateKey> {
let mut reader = BufReader::new(File::open(path)?);
let mut keys = pkcs8_private_keys(&mut reader)?;
if keys.is_empty() {
anyhow::bail!("no private keys found in {path}");
}
Ok(PrivateKey(keys.remove(0)))
}
pub fn build_tls_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<ServerConfig>> {
let certs = load_certs(cert_path)?;
let key = load_key(key_path)?;
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(Arc::new(config))
}
with_safe_defaults() is the part to notice: TLS 1.2 and 1.3, no weak ciphers, no compression. There’s no with_unsafe_defaults — the design is that you don’t get to make obviously wrong choices.
For TLS termination in front of axum, the common pattern in 2023 is to terminate at a load balancer or sidecar (Envoy, Nginx, ALB) and run axum HTTP-only internally. Direct rustls termination in axum is doable via axum-server with rustls features, but most production deployments push TLS to the edge.
Client config and reqwest
For outbound calls, the reqwest client should:
- Set an explicit timeout.
- Use a connection pool (default behavior, but make sure you reuse the Client).
- Use rustls (or native-tls, consciously).
- Optionally pin certificates for high-trust endpoints.
use reqwest::ClientBuilder;
use std::time::Duration;
pub fn build_http_client() -> anyhow::Result<reqwest::Client> {
let client = ClientBuilder::new()
.use_rustls_tls()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(3))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(16)
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
.https_only(true)
.build()?;
Ok(client)
}
https_only(true) is worth turning on for backend services that shouldn’t be calling http:// URLs by accident — particularly useful if you’re consuming a config file with URLs and want to fail closed on misconfiguration.
For certificate pinning (worth it for callbacks to known endpoints, payment gateways, etc.):
let cert = reqwest::Certificate::from_pem(include_bytes!("../certs/partner.pem"))?;
let client = ClientBuilder::new()
.use_rustls_tls()
.add_root_certificate(cert)
.tls_built_in_root_certs(false) // don't trust system CAs
.build()?;
Common Pitfalls
- Both TLS stacks linked. Forgetting
default-features = falseon reqwest or sqlx can pull in both rustls and native-tls. Checkcargo tree -e features | grep -E 'rustls|native-tls|openssl'to confirm one is active. - Static binary that quietly needs OpenSSL.
file target/release/yourbinandlddwill tell you. If it says “not a dynamic executable,” you’re fully static. If it listslibssl.so.3, you’re not. - Cert paths assumed at runtime. rustls uses webpki-roots for the trust store by default in many crates, but some setups read system CAs. Make the source of trusted CAs explicit in your config.
- Wrong key format. PEM-encoded PKCS#8 vs PKCS#1 vs RSA-specific — rustls 0.21’s pemfile parser handles most but not all. Standardize on PKCS#8 if you’re generating keys yourself.
- Renewing certs without reload. A long-running service holds the rustls config in memory. Cert renewal needs a reload signal (SIGHUP handler) or restart-on-rotation in your deployment.
- No SNI. When connecting to an IP and presenting a hostname, the server may not know which cert to serve. Set the hostname explicitly in your client and check that SNI is in the ClientHello (Wireshark will show it).
- mTLS without revocation checking. rustls supports CRLs in 0.21 via
rustls::server::AllowAnyAuthenticatedClientwith explicit CRL loading. Without it, revoked client certs are still trusted. For internal services this is often acceptable; for externally-facing endpoints, plumb in revocation.
A decision checklist
When I’m starting a new Rust backend service, I run through this:
- Is FIPS 140-2 required by contract or policy? If yes, OpenSSL with the FIPS module, or aws-lc-rs.
- Is the binary going into a distroless or alpine container? rustls.
- Is the security team running OpenSSL-specific audit tooling? Discuss with them; usually they’re fine with rustls but it’s a conversation.
- Are you integrating with a partner endpoint that has known TLS quirks? Test with rustls first; fall back to native-tls if you hit an interop wall.
- Default: rustls.
The default answer is rustls 95% of the time for new services in mid-2023. The 5% where it isn’t, the reason is usually compliance or a specific integration constraint, and you’ll know going in.
Wrapping Up
The TLS choice is one of those decisions that’s invisible until it isn’t. Picking rustls by default gives you simpler builds, smaller binaries, and a smaller CVE-prone surface area, with little real downside for typical backend workloads. Picking OpenSSL is the right call when compliance demands it or when you’re integrating with infrastructure that expects it. The rustls docs are worth reading once even if you stick with native-tls, just to understand what the safe-defaults model actually buys you.
This wraps the July Rust series — eight posts covering why the language is growing, the ownership and memory-safety foundation, async with tokio, error handling, CLIs, HTTP APIs, and TLS. The threads tie together into the working setup I’ve used for production Rust backends through the first half of 2023.