Programming languages

Rust "invalid peer certificate: UnknownIssuer": fixes

By · Last verified

Error messageinvalid peer certificate: UnknownIssuer

invalid peer certificate: UnknownIssuer means rustls could not connect the server's certificate chain to any root in the root store it was given. Browsers may accept the same certificate. Your program trusts a compiled-in Mozilla list or an empty container bundle, while the chain ends at a private CA or lacks its intermediate. Fix the root source.

What the error means

rustls found no root in its store that issued the server's chain. rustls returns Error::InvalidCertificate(CertificateError::UnknownIssuer), which displays as invalid peer certificate: UnknownIssuer and appears in Debug output as InvalidCertificate(UnknownIssuer). The rustls documentation describes the variant as a chain that is not issued by a known root certificate. In reqwest, hyper or tonic, it is the innermost source of the connection error:

error sending request for url (https://example.com/): ...: invalid peer certificate: UnknownIssuer

(reqwest's own message only names the URL; print the error with its source() chain, for example with anyhow's {:#}, to see the rustls part.)

Which roots count as "known" depends on how the crates were configured:

  • webpki-roots: Mozilla's public roots compiled into the binary; they change only when you update the crate.
  • rustls-native-certs: loads the operating system store at runtime; on Unix it honours SSL_CERT_FILE and SSL_CERT_DIR.
  • rustls-platform-verifier: delegates to the OS verifier on Windows, macOS and Android, and on Linux uses the system CA bundle with webpki.

reqwest 0.12's rustls-tls feature selects webpki-roots. reqwest 0.13 made rustls the default backend and uses rustls-platform-verifier by default, so upgrading changes which roots you trust.

Causes, most common first

1. A corporate or private CA that the root store does not contain

TLS-inspecting proxies and internal services issue certificates from roots that are not in Mozilla's list. With webpki-roots they can never be trusted; with native roots they must be installed on the machine.

  • Confirm: openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -issuer shows your company or a security vendor. See corporate SSL inspection.
  • Fix: use native roots or the platform verifier and install the root in the OS, or add it in code.

2. The server does not send its intermediate certificate

rustls verifies only the certificates the server sends plus its roots; it does not fetch missing intermediates.

3. The container has no CA bundle

scratch images, and slim images without the ca-certificates package, lack /etc/ssl/certs/ca-certificates.crt. With native roots, rustls then has nothing to trust and every site fails.

  • Confirm: ls -l /etc/ssl/certs/ inside the container.
  • Fix: install ca-certificates in the build stage and copy the bundle into the final image.

4. A self-signed development certificate

A self-signed server certificate is its own issuer and is unknown unless you add it as a root. Generate local certificates from a local CA instead, and trust that CA.

5. An outdated webpki-roots release

A public root added to Mozilla's program after your crate version was published is unknown. cargo update -p webpki-roots refreshes it.

Fix commands by platform

Pick a root source that contains the server's root: the OS store for corporate and private CAs, or compiled-in roots plus the added private root.

Check which root source you build with:

cargo tree -i webpki-roots
cargo tree -e features -i reqwest | head -20

reqwest 0.12, use the OS store instead of compiled-in roots (Cargo.toml):

reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] }

reqwest 0.13, add a private root while keeping the platform roots:

let pem = std::fs::read("/etc/ssl/certs/company-root.pem")?;
let client = reqwest::Client::builder()
    .tls_certs_merge(reqwest::Certificate::from_pem_bundle(&pem)?)
    .build()?;

On reqwest 0.12 use .add_root_certificate(reqwest::Certificate::from_pem(&pem)?).

Plain rustls with the OS store:

let mut roots = rustls::RootCertStore::empty();
roots.add_parsable_certificates(rustls_native_certs::load_native_certs().certs);
let config = rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth();

Linux hosts and images:

FROM debian:bookworm-slim AS certs
RUN apt-get update && apt-get install -y ca-certificates
COPY company-root.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates

FROM scratch
COPY your-binary /your-binary
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

Windows and macOS: with the platform verifier (reqwest 0.13 default), installing the root in the system store (Group Policy, certlm.msc, or Keychain) is enough.

Don't disable certificate verification

Accepting invalid certificates hides the error and trusts any server. danger_accept_invalid_certs(true) (renamed tls_danger_accept_invalid_certs in reqwest 0.13) or a custom rustls ServerCertVerifier that always returns success makes the error disappear by trusting everything. reqwest's own documentation warns that any certificate for any site will then be trusted, including expired ones, and that it should only be a last resort. Anyone on the network path could then impersonate your API and capture tokens.

The safer alternatives are precise: trust the OS store with native roots or the platform verifier, add the one private root with tls_certs_merge / add_root_certificate, ship a CA bundle in containers, and fix incomplete chains on the server.

Verify the fix

./your-binary https://example.com/
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ./your-binary https://example.com/   # native roots: test a specific bundle

The request must succeed without UnknownIssuer, on the same image or OS you deploy to. Confirm the server's chain in the SSL checker, and read certificate chains explained to see why a missing intermediate breaks rustls but not a browser.

FAQ

Why does curl or my browser trust the site when my Rust program does not?

They use different trust stores. Browsers and curl use the operating system store or the system bundle. A Rust binary built with webpki-roots carries a fixed copy of Mozilla's roots compiled in, so a corporate root installed on the machine has no effect. Switch to native roots or the platform verifier, or add the root explicitly.

Does updating webpki-roots help?

Only when the server chains to a new public root that your older webpki-roots release lacks. It never helps with private CAs or TLS-inspecting proxies, because webpki-roots contains only Mozilla's public roots.

My binary works on my laptop but fails in a scratch container. Why?

With native roots or the platform verifier on Linux, rustls reads the container's CA bundle. A scratch image has none, and some slim base images do not install the ca-certificates package. Install the ca-certificates package in the build stage and copy /etc/ssl/certs/ca-certificates.crt into the final image, or set SSL_CERT_FILE to a bundle you ship.

What about native-tls instead of rustls?

native-tls uses OpenSSL on Linux, Schannel on Windows and Security.framework on macOS, so it trusts the OS store by default and reports OpenSSL or platform errors instead of UnknownIssuer. It is a valid choice, but the same root-of-trust questions apply.

Sources

  1. rustls docs: CertificateError
  2. rustls-platform-verifier README
  3. rustls-native-certs README
  4. reqwest CHANGELOG (v0.13.0 TLS changes)
  5. reqwest docs: ClientBuilder