TLS & HTTPS
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9, axum-server 0.8.0, tokio-rustls 0.26 (rustls 0.23) and hyper-util 0.1.21 on Rust
1.99 (edition 2024), checked on 2026-10-06. axum::serve speaks plain HTTP only. HTTPS is added either in front of
the service (a reverse proxy or load balancer terminates TLS) or inside it (the process accepts TLS connections
itself).
Where to terminate TLS
| At a proxy or load balancer | In the axum process | |
|---|---|---|
Examples |
nginx, Caddy, Traefik, Envoy, a cloud load balancer, a Kubernetes ingress |
axum-server + rustls, a hand-written tokio-rustls loop |
Certificates |
managed centrally (ACME/Let’s Encrypt, cloud certificate managers) |
the service loads and reloads them |
Extra features |
HTTP/3, header timeouts, buffering, rate limits, WAF |
none beyond what you build |
Traffic behind it |
plain HTTP inside a trusted network (or re-encrypted) |
end-to-end encrypted |
Typical use |
almost every production deployment |
single-binary deployments, mTLS between services, development |
Bookshelf runs behind an ingress in production (see Deployment) and uses in-process TLS only for local HTTPS testing. When TLS ends at a proxy, the application must learn the original scheme and client address from forwarded headers — and trust them only from the proxy (see below).
HTTPS with axum-server and rustls
axum-server is a community-maintained server for axum Routers with TLS support through rustls (feature
tls-rustls, which uses the aws-lc-rs crypto provider) or OpenSSL (tls-openssl):
[dependencies]
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
rustls = "0.23" # only to select the crypto provider explicitly (see below)
use std::net::SocketAddr;
use axum::{
Router,
http::{HeaderMap, StatusCode, Uri, header, uri::Authority},
response::Redirect,
routing::get,
};
use axum_server::tls_rustls::RustlsConfig;
use tokio::signal::unix::{SignalKind, signal};
const HTTP_PORT: u16 = 3000;
const HTTPS_PORT: u16 = 3443;
/// Redirects every plain-HTTP request to the same path on HTTPS.
async fn redirect_to_https(headers: HeaderMap, uri: Uri) -> Result<Redirect, StatusCode> {
let host = headers.get(header::HOST).and_then(|h| h.to_str().ok()).ok_or(StatusCode::BAD_REQUEST)?;
let authority: Authority = host.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
let path = uri.path_and_query().map_or("/", |pq| pq.as_str());
Ok(Redirect::permanent(&format!("https://{}:{HTTPS_PORT}{path}", authority.host())))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Pick the process-wide crypto provider explicitly (see "Choosing the rustls crypto provider").
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let cert = std::env::var("TLS_CERT").unwrap_or_else(|_| "certs/cert.pem".into());
let key = std::env::var("TLS_KEY").unwrap_or_else(|_| "certs/key.pem".into());
let config = RustlsConfig::from_pem_file(&cert, &key).await?; // ALPN: h2 and http/1.1
// Reload the certificate on SIGHUP (e.g. after certbot renews it): new connections use it, old ones continue.
let reload = config.clone();
tokio::spawn(async move {
let mut hup = signal(SignalKind::hangup()).expect("signal handler");
while hup.recv().await.is_some() {
match reload.reload_from_pem_file(&cert, &key).await {
Ok(()) => tracing::info!("certificate reloaded"),
Err(err) => tracing::error!(%err, "certificate reload failed; keeping the old one"),
}
}
});
// Plain-HTTP listener that only redirects.
let redirect = Router::new().fallback(redirect_to_https);
let http = tokio::net::TcpListener::bind(("127.0.0.1", HTTP_PORT)).await?;
tokio::spawn(async move { axum::serve(http, redirect).await });
let app = Router::new().route("/", get(|| async { "hello over TLS\n" }));
axum_server::bind_rustls(SocketAddr::from(([127, 0, 0, 1], HTTPS_PORT)), config)
.serve(app.into_make_service())
.await?;
Ok(())
}
Source: axum-server 0.8 and axum example: tls-rustls
$ openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj '/CN=localhost' \
-addext 'subjectAltName=DNS:localhost' -keyout certs/key.pem -out certs/cert.pem # development only
$ curl -si localhost:3000/books?page=2 | grep -i -E '^HTTP|location'
HTTP/1.1 308 Permanent Redirect
location: https://localhost:3443/books?page=2
$ curl -s --cacert certs/cert.pem -w '%{http_version}\n' https://localhost:3443/
hello over TLS
2
Source: MDN: 308 Permanent Redirect and OpenSSL: openssl-req
-
The redirect reads the
Hostheader directly. axum-extra’sHostextractor would also consultX-Forwarded-Host, and is deprecated since axum-extra 0.12.4 for that reason. Behind a proxy, redirect at the proxy instead. -
Once HTTPS works, add
Strict-Transport-Security(see CSRF & Security Hardening) so browsers stop trying plain HTTP at all. -
The
curloutput shows HTTP/2: the client and server negotiatedh2with ALPN during the TLS handshake. Clients that only speak HTTP/1.1 gethttp/1.1. axum needs itshttp2feature for theh2side. -
axum_server::Handleprovides graceful shutdown for axum-server (handle.graceful_shutdown(Some(duration))), the counterpart ofaxum::serve'swith_graceful_shutdown(see Graceful Shutdown); thetls-graceful-shutdownexample combines both. -
For automatic Let’s Encrypt certificates in process, crates such as
rustls-acmeimplement the ACME protocol on top of rustls.
Choosing the rustls crypto provider
rustls 0.23 delegates cryptography to a provider: aws-lc-rs (the default) or ring. If exactly one is enabled in
the whole dependency graph, rustls picks it automatically. In a real service, different dependencies often enable
both (one through a database driver, another through an HTTP client), and the first TLS configuration then panics:
thread 'main' panicked at rustls-0.23.45/src/crypto/mod.rs:249:14:
Could not automatically determine the process-level CryptoProvider from Rustls crate features.
Call CryptoProvider::install_default() before this point to select a provider manually, or make sure exactly one of
the 'aws-lc-rs' and 'ring' features is enabled.
Installing a provider at the top of main, as both examples on this page do, makes the choice explicit and immune to
dependency changes. jsonwebtoken 11 has the same design with its own aws_lc_rs/rust_crypto features (see
JWT & Bearer Tokens).
A low-level accept loop with tokio-rustls
When you need full control — client certificates (mTLS), per-connection limits, custom ALPN — drive the TLS handshake
and hyper yourself. This is what axum-server does internally, and what the official low-level-rustls example shows:
use std::sync::Arc;
use axum::{Router, extract::Request, routing::get};
use hyper_util::{
rt::{TokioExecutor, TokioIo},
server::conn::auto::Builder,
service::TowerToHyperService,
};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
use tokio_rustls::{TlsAcceptor, rustls::ServerConfig};
use tower::ServiceExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
let certs = CertificateDer::pem_file_iter("certs/cert.pem")?.collect::<Result<Vec<_>, _>>()?;
let key = PrivateKeyDer::from_pem_file("certs/key.pem")?;
let mut config = ServerConfig::builder().with_no_client_auth().with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let acceptor = TlsAcceptor::from(Arc::new(config));
let app = Router::new().route("/", get(|| async { "hello from a hand-written TLS loop\n" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3443").await?;
loop {
let (tcp, peer) = listener.accept().await?;
let (acceptor, router) = (acceptor.clone(), app.clone());
tokio::spawn(async move {
// the TLS handshake happens on the connection's own task, so a slow client blocks nobody
let Ok(tls) = acceptor.accept(tcp).await else {
tracing::debug!(%peer, "TLS handshake failed");
return;
};
let service = TowerToHyperService::new(
router.map_request(|req: Request<hyper::body::Incoming>| req.map(axum::body::Body::new)),
);
if let Err(err) = Builder::new(TokioExecutor::new()).serve_connection_with_upgrades(TokioIo::new(tls), service).await {
tracing::debug!(%err, "connection error");
}
});
}
}
Source: axum example: low-level-rustls and tokio-rustls
The low-level-openssl and low-level-native-tls examples do the same with tokio-openssl and tokio-native-tls
(the operating system’s TLS stack). rustls is the usual choice in Rust: memory-safe, fast, and with no system library
to keep patched. For mTLS, build the ServerConfig with with_client_cert_verifier(...) and read the peer certificate
from the TLS stream to identify the client.
Behind a proxy: scheme and client address
When TLS ends at a proxy, every request reaches axum as plain HTTP from the proxy’s address. The proxy adds
X-Forwarded-Proto/X-Forwarded-For (or the standard Forwarded header, RFC 7239); use them to build absolute URLs,
to set Secure cookies and to log or rate-limit the real client — but only if the proxy overwrites them, and only
for requests that come from the proxy. A client talking to the app directly can send any value. Configure the proxy to
forward Host and Origin unchanged, which CsrfLayer relies on.
Unix domain sockets
A service that only a local proxy talks to can listen on a Unix domain socket instead of a TCP port: no port to
allocate, and file permissions control access. Since axum 0.8, axum::serve accepts any Listener, including
tokio::net::UnixListener:
use axum::{Router, routing::get};
use tokio::net::UnixListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::temp_dir().join("bookshelf.sock");
let _ = std::fs::remove_file(&path); // a stale socket file from a previous run prevents binding
let listener = UnixListener::bind(&path)?;
println!("listening on {}", path.display());
let app = Router::new().route("/health", get(|| async { "ok over a Unix socket\n" }));
axum::serve(listener, app).await?;
Ok(())
}
Source: axum example: unix-domain-socket
$ curl -s --unix-socket /tmp/bookshelf.sock http://localhost/health
ok over a Unix socket
Source: axum: Listener and axum example: unix-domain-socket
ConnectInfo works with Unix sockets too, yielding the peer’s UnixSocketAddr (and, with extra work, its credentials
through peer_cred).
Where the books are outdated
Crossley et al. (Rust for Scientific Computing, ch. 15) serves a token-protected API over plain HTTP; never send
credentials without TLS. Older axum TLS examples use axum_server::bind_rustls with axum 0.6 and rustls-pemfile; with
rustls 0.23 the PEM parsing lives in rustls-pki-types (PemObject), and rustls-pemfile is no longer needed. Before
axum 0.8, axum::serve only accepted a TcpListener, so Unix sockets required the hyper-level loop.