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(())
}
$ 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
  • The redirect reads the Host header directly. axum-extra’s Host extractor would also consult X-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 curl output shows HTTP/2: the client and server negotiated h2 with ALPN during the TLS handshake. Clients that only speak HTTP/1.1 get http/1.1. axum needs its http2 feature for the h2 side.

  • axum_server::Handle provides graceful shutdown for axum-server (handle.graceful_shutdown(Some(duration))), the counterpart of axum::serve's with_graceful_shutdown (see Graceful Shutdown); the tls-graceful-shutdown example combines both.

  • For automatic Let’s Encrypt certificates in process, crates such as rustls-acme implement 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");
            }
        });
    }
}

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(())
}
$ curl -s --unix-socket /tmp/bookshelf.sock http://localhost/health
ok over a Unix 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.