# 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](/docs/connect)). 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:

```bash
# 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:

```bash
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
```

```text
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](https://www.rfc-editor.org/rfc/rfc8705) 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](/docs/per-agent-ca) and a deterministic leaf whose key is [DANE-pinned](/docs/dane) at the agent's own name. Three checks have to pass:

1. the presented leaf **chains to the per-agent CA root**;
2. it carries an `iPAddress` SAN that is a real allocated `/128` under `2a04:2a01::/32` (AS219419) — and *that* SAN, **not** whatever address the TLS connection happens to originate from, is the `/128` egress is bound to;
3. its public key **matches the DANE `TLSA` pin** 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:**

```bash
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:

```text
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](https://www.rfc-editor.org/rfc/rfc9449) — 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:

```text
{"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:

```bash
# 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](/docs/per-agent-ca) for where the certificate comes from, or [DANE & DNSSEC](/docs/dane) for the pin the certificate has to match.
