Why TLS validation matters

TLS protects data in transit, but only if the client verifies the server’s identity. Without verification, an attacker on the network can impersonate a service and intercept credentials, tokens, or API responses.

In Rust, the risk often appears in one of these forms:

  • Disabling certificate checks during development and forgetting to re-enable them
  • Using a custom root store incorrectly
  • Accepting self-signed certificates in production
  • Connecting to an IP address while expecting hostname validation to protect you
  • Following redirects to unexpected hosts without policy checks

A secure client should do three things consistently:

  1. Verify the certificate chain.
  2. Verify the server name matches the certificate.
  3. Use a trusted root store and sane protocol settings.

Common failure modes

The most dangerous TLS mistakes are usually configuration mistakes, not cryptographic ones.

Failure modeRiskSafer approach
danger_accept_invalid_certs(true)Accepts any certificate, enabling MITM attacksKeep validation enabled; use a proper CA or pinned trust anchor
danger_accept_invalid_hostnames(true)Ignores hostname mismatchEnsure the request URL matches the certificate SAN/CN
Trusting arbitrary custom rootsExpands trust to attacker-controlled certsLoad only explicit, audited roots
Following redirects blindlyCan send secrets to a different originRestrict redirects and re-check destination
Using HTTP instead of HTTPSNo transport confidentiality or integrityRequire HTTPS for sensitive requests

The key idea is simple: if your client cannot prove it is talking to the intended server, it should stop.

Choosing a client library

Rust has several HTTP clients, but the security model differs slightly.

LibraryTLS backendNotes
reqwestrustls or native-tlsHigh-level API, good defaults, easy to configure
hyperUsually paired with rustls or native-tlsLower-level; more control, more responsibility
ureqrustls or native-tlsLightweight synchronous client
awcrustls or native-tlsCommon in Actix ecosystems

For most applications, reqwest is the best starting point because it exposes secure defaults and makes it easy to keep validation on.

Secure defaults with reqwest

The safest pattern is to rely on the default TLS behavior and avoid “danger” settings unless you are in a tightly controlled test environment.

use reqwest::Client;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let client = Client::builder()
        .timeout(Duration::from_secs(10))
        .build()?;

    let response = client
        .get("https://api.example.com/v1/status")
        .send()
        .await?
        .error_for_status()?;

    let body = response.text().await?;
    println!("{body}");

    Ok(())
}

This example does not disable certificate checks, does not accept invalid hostnames, and uses HTTPS explicitly. For production code, that should be your baseline.

What not to do

Avoid code like this outside of isolated local testing:

use reqwest::Client;

let client = Client::builder()
    .danger_accept_invalid_certs(true)
    .danger_accept_invalid_hostnames(true)
    .build()?;

These options remove the very checks that make HTTPS trustworthy. If you need to talk to a local service with a self-signed certificate, prefer a local CA or a pinned certificate in a development-only environment.

Using a custom root store safely

Sometimes you need to trust an internal CA, such as in enterprise environments or service-to-service communication inside a private network. In that case, add only the exact root certificates you intend to trust.

With rustls, you can build a custom root store and load a specific PEM file:

use reqwest::Client;
use rustls::RootCertStore;
use rustls_pemfile::certs;
use std::fs::File;
use std::io::BufReader;

fn load_root_store(path: &str) -> Result<RootCertStore, Box<dyn std::error::Error>> {
    let file = File::open(path)?;
    let mut reader = BufReader::new(file);

    let mut store = RootCertStore::empty();
    let loaded = certs(&mut reader)
        .collect::<Result<Vec<_>, _>>()?;

    for cert in loaded {
        store.add(cert.into())?;
    }

    Ok(store)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _roots = load_root_store("certs/internal-ca.pem")?;

    let client = Client::builder()
        .use_rustls_tls()
        .build()?;

    let res = client.get("https://internal-api.example.local").send().await?;
    println!("status = {}", res.status());

    Ok(())
}

In real deployments, you would wire the custom root store into the TLS connector rather than just loading it. The important security principle is that the trust anchor should be explicit, minimal, and version-controlled. Do not import a broad bundle of unknown certificates just to “make it work.”

Certificate pinning: when it helps and when it hurts

Pinning means trusting a specific certificate or public key instead of a general CA hierarchy. It can be useful for high-value internal services, mobile backends, or tightly controlled device fleets.

However, pinning also increases operational risk:

  • Certificates expire and must be rotated.
  • Key rotation requires application updates or a pin set.
  • Mismanaged pins can cause outages.

Use pinning only when you can manage lifecycle complexity carefully. For most public internet services, validating against the system or Mozilla root store is safer and easier to maintain.

A practical rule:

  • Use CA validation for public services.
  • Use pinning only for controlled environments with a clear rotation plan.

Hostname validation and IP addresses

A certificate is issued to a name, not to “whatever server answered the socket.” If you connect to https://192.0.2.10/, the certificate must be valid for that IP address, or the client should reject it.

This matters in containerized and internal environments where developers often hardcode IPs for convenience. Prefer stable DNS names and certificates that include the correct Subject Alternative Names.

Safer pattern

  • Use service DNS names like https://payments.internal.example
  • Issue certificates for those names
  • Avoid bypassing hostname checks to “fix” local connectivity issues

If you must connect by IP in a test harness, keep that behavior isolated from production code paths.

Redirects and origin changes

TLS validation only applies to the endpoint you actually connect to. If your client follows redirects, a secure initial request can still end up at a different origin.

That becomes dangerous when:

  • Authorization headers are automatically forwarded
  • Cookies are reused across domains
  • The redirect target is attacker-controlled

A safer policy is to limit redirects and inspect the destination before following them. For sensitive requests, consider disabling automatic redirects entirely and handling them manually.

use reqwest::Client;
use reqwest::redirect::Policy;

let client = Client::builder()
    .redirect(Policy::limited(5))
    .build()?;

For APIs carrying credentials, you may want an even stricter policy: only follow redirects within the same host, or not at all.

Timeouts and failure handling

TLS validation is only one part of secure transport. A client that hangs indefinitely can still create availability problems. Set reasonable timeouts for connection establishment, TLS handshake, and response reads.

Recommended practices:

  • Use a connect timeout to avoid slow connection stalls
  • Use a total request timeout for end-to-end control
  • Treat handshake failures as hard errors
  • Log enough context to diagnose failures without exposing secrets

Do not retry TLS verification failures automatically. If a certificate is invalid, repeated attempts do not make the connection safer; they just create noise and may mask a real attack.

Development and testing without weakening production

Developers often weaken TLS during local testing because it is convenient. That convenience can leak into production through shared configuration or feature flags.

Safer alternatives include:

  • Running a local CA with tools like mkcert
  • Using a dedicated development domain and certificate
  • Keeping insecure settings behind compile-time test-only code
  • Failing closed when a production environment detects insecure TLS options

A good practice is to make insecure configuration impossible to enable accidentally. For example, separate development and production configuration files, and validate them at startup.

A practical checklist

Before shipping an HTTP client, verify the following:

  • HTTPS is required for sensitive endpoints
  • Certificate chain validation is enabled
  • Hostname validation is enabled
  • Custom roots are explicit and minimal
  • Redirects are restricted
  • Timeouts are configured
  • Insecure test-only settings are not compiled into production
  • Errors are surfaced clearly and treated as security-relevant

If your client library exposes “danger” methods, assume they are for controlled testing only.

Example: secure client wrapper

A small wrapper can help centralize policy and prevent ad hoc insecure configuration.

use reqwest::{Client, StatusCode};
use std::time::Duration;

pub struct SecureHttpClient {
    client: Client,
}

impl SecureHttpClient {
    pub fn new() -> Result<Self, reqwest::Error> {
        let client = Client::builder()
            .https_only(true)
            .timeout(Duration::from_secs(15))
            .redirect(reqwest::redirect::Policy::limited(3))
            .build()?;

        Ok(Self { client })
    }

    pub async fn get_text(&self, url: &str) -> Result<String, reqwest::Error> {
        let response = self.client.get(url).send().await?.error_for_status()?;
        response.text().await
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let http = SecureHttpClient::new()?;
    let body = http.get_text("https://example.com").await?;
    println!("{body}");
    Ok(())
}

This wrapper does not expose insecure toggles. That design choice is important: security is easier to preserve when the API makes the safe path the default path.

Conclusion

TLS security in Rust is less about cryptographic primitives and more about disciplined client configuration. The most serious mistakes come from disabling validation, trusting too much, or letting convenience override policy.

If you keep validation enabled, use explicit trust anchors, limit redirects, and enforce HTTPS-only behavior for sensitive traffic, your Rust HTTP clients will be far more resilient against interception and impersonation attacks.

Learn more with useful resources