mTLS & DPoP: cryptographic egress authentication
A bearer token is a shared secret, and shared secrets leak — into a log line, a subprocess's ps aux, a prompt an LLM echoes back, a request captured mid-flight.
For a human clicking around a browser that's an annoyance you rotate away. For an autonomous agent running unattended, a leaked egress token means anyone who finds it can reach the internet as that agent, from anywhere, until someone notices and revokes it. Whisper closes that gap three ways, each stronger than the last, none requiring you to change how your code makes requests.
Every agent egresses through a proxy bound to its own /128 (see Connect & egress). What differs is how the agent proves it's allowed to use that connection. Whisper picks the strongest proof present and never blocks a weaker valid one — the same fail-open, Postel's-Law posture as everything else on this network.
The default: a bound bearer
By default, connect mints a bearer token — an opaque et_… string — that is scoped to the agent's own /128. It isn't just "a token that happens to work"; the gateway checks the token's owning address against the actual source address of the proxy session before it relays a single byte. Copy the string onto a different machine or a different agent's connection and it is refused — the credential is bound to the identity that minted it, not merely to whoever holds the string.
That's real protection with zero setup, and it's why it's the default. It is still, structurally, a bearer: whoever holds it and controls that /128 can use it. The next two mechanisms remove even that assumption by requiring proof of a private key on every use.
With stock tools — presenting the bearer over an HTTP CONNECT proxy:
# connect returned: http_proxy=http://127.0.0.1:1080, bearer et_9f2c...
curl --proxy-header "Proxy-Authorization: Bearer et_9f2c...a41d" \
-x http://127.0.0.1:1080 \
https://example.com
With Whisper — the CLI or SDK handles the header for you:
whisper connect my-agent # writes ALL_PROXY / HTTPS_PROXY with the bearer embedded
curl https://example.com # inherits it — no code change
# or, one line:
whisper run -- curl https://example.com
CALL whisper.agents({op:'connect', args:{agent:'my-agent', tier:'socks5'}})
-> { http_proxy, connection_string, dns, doh_url } # bearer embedded, /128-scoped
mTLS — the certificate is the proof (RFC 8705)
RFC 8705 defines mutual-TLS client authentication and certificate-bound access tokens for OAuth 2.0: instead of (or alongside) a bearer, the client presents its own certificate during the TLS handshake, and the server validates it as the credential. Whisper already has the certificate to present — every agent has its own per-agent CA and a deterministic leaf whose key is DANE-pinned at the agent's own name. Three checks have to pass:
- the presented leaf chains to the per-agent CA root;
- it carries an
iPAddressSAN that is a real allocated/128under2a04:2a01::/32(AS219419) — and that SAN, not whatever address the TLS connection happens to originate from, is the/128egress is bound to; - its public key matches the DANE
TLSApin published for that name — the certificate and the DNS have to agree.
Pass all three and the agent is authenticated cryptographically — no bearer required at all. Because the identity now rides in the certificate rather than in the source address, this is more portable than the bound bearer, not just stronger: present the leaf from anywhere — a CI runner, a laptop, a different cloud — and your traffic still sources from your agent's /128. The cert is the identity, decoupled from wherever the handshake started. And per RFC 8705 §3, any token minted over that mTLS connection is certificate-bound (cnf.x5t#S256, the SHA-256 thumbprint of the client cert): even if that token later leaked, it's inert without the private key that produced the handshake.
Fetch your agent's leaf and key once from the control plane, then present them like any TLS client cert:
With stock tools:
openssl s_client -connect egress.whisper.online:443 \
-cert acef2002a323d40d4-leaf.pem -key acef2002a323d40d4-key.pem -quiet
# handshake completes with the CertificateRequest satisfied — no bearer sent
curl --proxy-cert acef2002a323d40d4-leaf.pem \
--proxy-key acef2002a323d40d4-key.pem \
-x https://egress.whisper.online:443 \
https://example.com
With Whisper — issue the credential once over the (already-authenticated) control plane:
CALL whisper.agents({op:'identity', args:{agent:'my-agent', clientCert:true}})
-> { cert: "-----BEGIN CERTIFICATE-----...", key: "-----BEGIN PRIVATE KEY-----..." }
Write those two PEMs to disk and every subsequent curl --proxy-cert/--proxy-key (or any TLS client's client-cert option) authenticates as that agent — no bearer in the request at all.
DPoP — proof-of-possession per request (RFC 9449)
mTLS needs the TLS layer itself to terminate at the point of authentication, which isn't always true across every proxy chain. RFC 9449 — DPoP, "Demonstrating Proof-of-Possession" — solves the same problem one layer up, at the HTTP request itself. The client signs a small JWT and attaches it as a DPoP header on every request:
{"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":"...","y":"..."}}
.
{"htm":"CONNECT","htu":"https://example.com","iat":1751500000,
"jti":"c185b2e0-...", "ath":"b64url(SHA-256(bearer))"}
The server verifies the signature against the embedded public key (jwk), checks htm/htu match the actual request, rejects anything outside a tight freshness window on iat, and refuses a repeated jti — a captured proof cannot be replayed even seconds later. The ath claim (hash of the bearer) binds the proof to one specific token, so a stolen bearer and a captured proof are both individually useless: an attacker needs the private key that signs fresh proofs, every time. Whisper's implementation is strict ES256-only, per DPoP's typ/alg conventions — Postel's Law applies to what we accept as input format, not to weakening the cryptography itself.
With stock tools — hand-build a proof and attach it to the CONNECT request:
# generate an ES256 key once, build {header}.{payload}, sign with openssl, base64url-encode
DPOP=$(python3 - <<'EOF'
import json, base64, time, hashlib
# ... build header/payload, sign with the P-256 key, base64url the JWS ...
EOF
)
curl --proxy-header "DPoP: $DPOP" \
--proxy-header "Proxy-Authorization: Bearer et_9f2c...a41d" \
-x http://127.0.0.1:1080 https://example.com
With Whisper — the CLI generates the P-256 key, signs a fresh proof per request, and attaches ath/jti/iat automatically when the egress tier has DPoP enabled; nothing in your code changes from the bearer-only example above.
Honest status. mTLS is implemented and has run live end-to-end on the network — the handshake, the DANE-consistency check, and cert-bound tokens all proven against a real agent. DPoP is implemented to the full RFC 9449 spec in the same binary — proof validation, replay cache, ath binding — but is not yet the default-enabled path in production. If your deployment needs request-level proof-of-possession today, ask; the code path exists and is tested, it's a rollout step away from on by default.
Choosing a mechanism
| Mechanism | Proves | Binds to | Setup |
|---|---|---|---|
| Bound bearer (default) | possession of the token string | the agent's /128 |
none — the default from connect |
| mTLS (RFC 8705) | possession of the per-agent private key, at the TLS layer | the DANE-pinned leaf certificate | fetch the leaf+key once via op:identity |
| DPoP (RFC 9449) | possession of the private key, per HTTP request | a signed proof, hashed to a specific bearer | sign one small JWT per request |
None of these change what your code sees: standard HTTPS_PROXY/ALL_PROXY semantics, standard TLS, standard headers. What changes is what a thief gets from a leaked string — with mTLS or DPoP in play, the answer is nothing usable.
Next: Per-agent CA for where the certificate comes from, or DANE & DNSSEC for the pin the certificate has to match.