Skip to content

TLS Configuration Guide ​

HTTPX implements Transport Layer Security with full cross-platform support across Linux, Windows, and macOS.

Support Scope (read this first) ​

  • Client (httpx.Client → https://): HTTPS/1.1 without client certificates runs on std.crypto.tls (TLS 1.2/1.3) with SNI, system + custom trust stores, and hostname verification. Setting .tls = .{ .clientCertPem, .clientKeyPem } (mTLS) or explicit .httpVersion = .http2 switches that request to the native client instead, adding certificate presentation and/or ALPN (h2 for HTTP/2, http/1.1 otherwise) on top of the same verification.
  • Server (native engine in src/protocols/tls/): TLS 1.3 only, with X25519 ECDHE, AES-128-GCM / AES-256-GCM / ChaCha20-Poly1305 record protection, SNI parsing, and ALPN dispatch. Server certificates must be P-256 ECDSA (ecdsa_secp256r1_sha256); RSA/private-key types are rejected loudly with UnsupportedSignatureScheme instead of emitting a broken handshake.
  • Session resumption (TLS 1.3 PSK, psk_dhe_ke): the native client offers cached sessions and the native server issues stateless tickets — see Session Resumption below. Resumption covers the native paths (HTTP/2 over TLS always; HTTPS/1.1 when already native via client certificates). Plain HTTPS/1.1 runs on std.crypto.tls, which exposes no ticket API, so it always does full handshakes.
  • HelloRetryRequest: fully handled on both sides (shareless hello → retry with share, single-retry guard, transcript splice). Only X25519 is supported: a server selecting any other group fails loudly.
  • 0-RTT Early Data (RFC 8446 & RFC 9001): Fully supported across TLS 1.3, QUIC TLS, and HTTP/3. Safe by default: early data is disabled unless explicitly enabled via .earlyData = .{ .enabled = true }. Safe HTTP methods (GET, HEAD, OPTIONS) are permitted by default; replay-sensitive methods (POST, PUT, PATCH, DELETE) require explicit .allowUnsafeMethods = true. Servers enforce bounded anti-replay defense via ReplayCache, ticket age checks, and maxEarlyData bounds.

Mutual TLS (Client Certificates) ​

The server can require (or optionally accept) client certificates:

zig
var server = try httpx.Server.init(allocator, io, .{
    .host = "127.0.0.1",
    .port = 0,
    .tls = .{
        .certPem = cert_pem,
        .keyPem = key_pem,
        .clientAuth = .required, // or .optional / .disabled (default)
        .clientCa = ca_pem,      // PEM bundle trusted for client chains
    },
});

With .required, a missing certificate fails the handshake: the client observes error.ClientCertificateRequired (its native handshake refuses to continue cert-less) while the server side only ever sees the connection vanish mid-flight (error.IoError / error.TlsHandshakeFailed) — there is no server-side policy error to assert on. Presented chains must anchor in clientCa with valid signatures (error.ClientCertificateInvalid otherwise). .optional lets cert-less clients through while still verifying any presented chain. See [TLS mTLS](/examples/tls-mtls) for a runnable loopback demo.

A client presents its certificate through the high-level API — no engine calls needed:

zig
var res = try client.get("https://service.internal/", .{
    .tls = .{
        .verify = .caBundle,
        .caPem = ca_pem, // extra trust anchor for the server chain
        .clientCertPem = cert_pem, // presented when requested
        .clientKeyPem = key_pem,   // P-256 ECDSA key for the chain
    },
});
defer res.deinit();

Omitting the pair on a .required server fails the request loudly; a non-http/1.1 ALPN answer on the HTTP/1.x path (and anything but h2 on the .http2 path) fails with error.AlpnNegotiationFailed instead of silently downgrading.

Session Resumption (PSK / Tickets) ​

TLS 1.3 resumption (RFC 8446 Sections 4.6.1, 4.2.11) abbreviates repeat handshakes: no Certificate/CertificateVerify flight, authentication via the PSK binder, forward secrecy preserved (psk_dhe_ke always performs fresh ECDHE alongside the PSK).

Server — opt in with ticket keys (stateless; no per-client storage):

zig
var server = try httpx.Server.init(allocator, io, .{
    .port = 8443,
    .tls = .{
        .certPem = cert_pem,
        .keyPem = key_pem,
        .ticketKeys = httpx.tls.TicketKeys.generate(),
        .ticketLifetimeSecs = 7200,
    },
});

Rotate with keys.rotate(next); outstanding tickets stay valid through one rotation via the previous-key slot, then fail closed (clients fall back to full handshakes — never an alert storm).

Client — automatic on the native paths: httpx.Client keeps an origin-keyed session cache, offers usable tickets, captures new ones from NewSessionTicket messages during reads, and resumes transparently. No API changes needed.

Rules that keep resumption honest:

  • Any ticket problem (unknown/expired/corrupt ticket, binder mismatch, suite mismatch) silently falls back to a full handshake — the client cannot distinguish either way.
  • Resumption is disabled under mutual TLS: an abbreviated flight carries no CertificateRequest, so resumed connections would bypass client certificate authentication. Servers with clientAuth set always do full handshakes.
  • Only SHA-256 suites resume (AES_128_GCM_SHA256, CHACHA20_POLY1305_SHA256); tickets for other hashes are ignored.
  • Tickets bind to the issuing origin host; the client never offers a ticket to a different host.

Client HTTPS Usage ​

HTTPS works out of the box with zero configuration:

zig
var client = httpx.Client.init(allocator, io, .{});
defer client.deinit();

// Automatically performs TLS 1.3 handshake with SNI and ALPN
const response = try client.get("https://cloudflare.com", .{});
defer response.deinit();

Custom CA Bundles & Self-Signed Certs ​

For local development or internal PKI:

zig
const response = try client.get("https://internal.corp", .{
    .tls = .{
        .verify = .selfSigned, // Accept self-signed certificates
        .allowTruncation = true,
    },
});
defer response.deinit();

Server TLS Configuration ​

HTTPX provides first-class native TLS/HTTPS support directly integrated into httpx.Server.

To launch a secure HTTPS server, supply PEM-encoded certificate chain and private key (either as in-memory PEM string or as file path):

zig
const certPem = @embedFile("certs/server.crt");
const keyPem = @embedFile("certs/server.key");

var server = try httpx.Server.init(allocator, io, .{
    .port = 8443,
    .tls = .{
        .certPem = certPem,
        .keyPem = keyPem,
        .allowPlainHttp = false, // strict HTTPS mode (default)
    },
});
defer server.deinit();

try server.get("/", helloHandler);

server.run();

Handlers can inspect the connection's encryption status via Context (ctx.isTls, ctx.scheme()), as in helloHandler below:

zig
fn helloHandler(ctx: *httpx.Context) anyerror!httpx.Response {
    if (ctx.isTls) return ctx.text("Hello HTTPS!");
    return ctx.text("Hello HTTP!");
}

Dynamic TLS Reconfiguration ​

You can inspect and reconfigure TLS at runtime without stopping the server:

zig
// Check if TLS is currently active
if (server.isTls()) {
    std.debug.print("Server running in HTTPS mode\n", .{});
}

// Dynamically rotate or enable certificates and keys
try server.setTls("certs/new_cert.pem", "certs/new_key.pem");

When rotating or deinitializing certificates, private key memory in heap buffers is automatically zeroed using std.crypto.secureZero before release.

Handshake Detection & Strict HTTPS Rejection ​

When TLS is active on the server port, HTTPX peeks at the initial connection bytes:

  • If the bytes start with the TLS record header (0x16 0x03), HTTPX executes the TLS server handshake.
  • If a plain HTTP request (e.g. GET / HTTP/1.1) arrives on an HTTPS port and allowPlainHttp is false (the default), HTTPX immediately rejects the connection with:
    http
    HTTP/1.1 400 Bad Request
    Content-Type: text/plain
    Connection: close
    
    The plain HTTP request was sent to HTTPS port
  • If allowPlainHttp = true, HTTPX seamlessly routes cleartext HTTP requests on the same port (dual HTTP/HTTPS mode, ideal for local testing).

ALPN Protocol Negotiation ​

During the TLS handshake, HTTPX negotiates the application protocol via ALPN:

  1. h2: Dispatched to HTTP/2 binary multiplexed connection handler.
  2. http/1.1: Dispatched to HTTP/1.1 connection pipeline.

Connection Security & Context ​

Handlers can inspect the connection's encryption status via Context:

  • ctx.isTls: bool indicating whether the request was received over TLS.
  • ctx.scheme(): Returns "https" for TLS connections (or if trusted X-Forwarded-Proto indicates HTTPS).

Structured Event Logging ​

When a client fails TLS handshakes (malformed ClientHello, unsupported ciphers, or aborted handshake), HTTPX emits a structured non-allocating event:

  • event.kind == .tlsHandshakeFailed This allows application observability without emitting unauthorized stdout/stderr noise.

Error Taxonomy ​

When certificate verification or handshake fails, HTTPX returns precise error types:

  • error.CertificateExpired: Certificate validity window has passed.
  • error.CertificateHostMismatch: Certificate SAN/CN does not match requested host.
  • error.CertificateIssuerMismatch: Intermediate CA signature could not be verified.
  • error.TlsCertificateNotVerified: Certificate not trusted by root CA store.
  • error.TlsHandshakeFailed: General TLS protocol or alert failure.

Released under the MIT License.