Relay Authentication (Mutual TLS)¶
Before any tunnel reaches the relay, it must pass a mutual-TLS (mTLS) handshake and route match. The relay's Caddy front door is configured to verify if given an X.509 client certificate: a connection that presents one must have it signed by an enrolled server's own certificate authority, or the handshake is rejected outright. A connection that presents no certificate at all still completes the handshake — that's deliberate, so a not-yet-enrolled invitee can reach the enrollment endpoint — but every tunnel route additionally requires a verified certificate whose subject CN and issuing CA both match that tenant; anything else, certed or not, falls through to a uniform 404.
This is the relay's primary admission control. The older VLESS UUID check is kept as harmless defense-in-depth and is no longer the security boundary.
Cross-tenant isolation in a shared trust pool
On a multi-tenant relay, Caddy admits any certificate that chains to any enrolled CA in its trust pool, then each tenant route matches on the certificate. Matching the subject CN alone would let one tenant mint a certificate carrying another tenant's CN off its own (pool-trusted) CA and reach the victim's upstream. Two checks close that: each route requires the certificate's issuer to be that tenant's CA (CN=<server-id>, since every per-server CA's subject is its own server-id), and enrollment refuses a joining server whose submitted CA subject does not equal its server-id — so no tenant can register a CA that impersonates another's identity.
Why it exists¶
The relay listens only on :80/:443 and is reachable from the public internet. Without certificate-gated routing, anyone who discovers the relay could reach the VLESS/SSH machinery behind Caddy and probe for UUIDs or SSH usernames. Route-gating every tenant handle on a verified client certificate:
- Shields the relay's tunnels from unauthenticated traffic. A connection without a valid, matching certificate never reaches a tenant's VLESS inbound — it falls through to a uniform 404, same response whether the path is wrong, the certificate is missing, or the certificate doesn't match that tenant (DoS and probing resistance). The one deliberate exception is the enrollment endpoint, which a bare handshake can reach — that's the surface
tw relay inviteneeds, and it's a short-lived, single-use code behind it, not open tunnel access. - Anchors trust in a per-server CA. The relay trusts exactly one certificate authority per enrolled server. Only that server (and the clients it hands the certificate to) can establish a tunnel — and only to that server's own route.
- Preserves end-to-end encryption. mTLS is admission, not decryption — the relay still forwards opaque streams and never sees plaintext. SSH remains the end-to-end layer.
The per-server certificate authority¶
Each server runs its own small certificate authority. There is one CA per server, and every client certificate it issues carries the same common name — the server-id (<hostname>-<first 8 hex of the profile UUID>) — since that's what the relay's per-tenant route matcher checks. How the certificate reaches a client differs by path:
| Artifact | Curve / validity | Stored at | Leaves the server? |
|---|---|---|---|
CA certificate (ca.crt) | ECDSA P-256, 10 years, CN = server-id | <config-dir>/ca.crt | Public cert only → shipped to the relay trust pool (/etc/caddy/ca/<server-id>.crt) |
CA private key (ca.key) | ECDSA P-256 | <config-dir>/ca.key | Never |
Server's own client certificate (client.crt) | ECDSA P-256, 5 years, CN = server-id, ExtKeyUsage: clientAuth | <config-dir>/client.crt | Only its public half — presented by tw server start itself, never handed to a client |
Server's own client private key (client.key) | ECDSA P-256 | <config-dir>/client.key | Never |
| An invited client's certificate | ECDSA P-256, 5 years, CN = server-id (forced by the signer, ignoring the CSR's requested subject), ExtKeyUsage: clientAuth | Client's own config dir | N/A — issued directly to the client from their CSR; the matching private key is generated on the client and never existed on the server |
The CA is generated automatically the first time a server or relay profile initializes (any tw command that touches ops — e.g. tw server start, tw relay create); generation is skipped in client mode. Generation is idempotent and self-healing: an existing CA is never regenerated, and if the server-id changes (hostname or UUID change) certificates are re-issued to match.
Same CN, but no longer one shared keypair
Every certificate issued by a server's CA carries that server's CN, so the relay's route matcher treats all of a server's clients — and the server itself — identically: admission and routing are per-server, not per-user. What changed with invite-based enrollment is the key material: tw server user invite signs each client's own CSR (internal/pki.SignClientCSR), so every invited client holds a distinct keypair with a certificate the server never possessed the private half of. Per-user identity and authorization are enforced one layer deeper regardless, by the per-user SSH key and its permitopen restrictions (see Access Control). Keeping admission per-server means the relay still needs no certificate revocation list — revoking a user is purely an SSH-key operation on the server, and revoking a whole server is tw relay un-enroll-server, which drops its CA from the trust pool (invalidating every certificate it ever issued, in one step).
How a connection is admitted¶
sequenceDiagram
participant C as Client (Xray)
participant Caddy as Relay · Caddy (:443)
participant X as Relay · Xray (per-tenant inbound)
participant S as Server (SSH)
C->>Caddy: TLS 1.3 ClientHello (+ client.crt, if any)
Note over Caddy: client_auth verify_if_given<br/>verify client.crt against trust_pool, IF one was presented
alt certificate presented but not signed by a trusted CA
Caddy--xC: handshake rejected (no app data exchanged)
else no certificate, or a certificate that verified
Note over Caddy: route by path /tw/#lt;server-id#gt; AND<br/>certificate subject CN=#lt;server-id#gt;
alt path + verified CN match a tenant
Caddy->>X: reverse_proxy (h2c) the VLESS/XHTTP stream
X->>S: opaque stream → server's reverse SSH tunnel
Note over C,S: SSH provides end-to-end encryption
else no match (no cert, wrong CN, or unmatched path)
Caddy--xC: 404 (same response either way)
end
end - The client's embedded Xray opens a TLS 1.3 connection to the relay and presents
client.crtduring the handshake. - Caddy verifies a presented certificate against its trust pool (the union of all enrolled servers'
ca.crtfiles) — a certificate that fails verification is rejected at the handshake, but presenting none at all is allowed, so an invitee with no certificate yet can still reach/enroll. - Every tunnel route additionally requires a match: the XHTTP path (
/tw/<server-id>) and the certificate subject (CN=<server-id>) must agree. A valid certificate for server A cannot reach server B's upstream. - On a match, Caddy reverse-proxies the stream to that server's local Xray inbound (
h2c://127.0.0.1:<remote-port + 10000>), which carries it to the server's reverse SSH tunnel. Anything that doesn't match a tenant route — no certificate, a wrong CN, or an unrecognized path — falls through to the same uniform 404, so a probe can't distinguish "wrong certificate" from "no such tenant."
Client-side: presenting the certificate¶
The client's Xray outbound (shared by both tw server start and tw client connect) injects the certificate into the TLS settings when client_cert_path/client_key_path are set:
"tlsSettings": {
"serverName": "relay.example.com",
"certificates": [
{
"certificateFile": "/etc/tw/config/client.crt",
"keyFile": "/etc/tw/config/client.key",
"usage": "client-cert"
}
]
}
The paths are auto-derived at runtime from <config-dir>/client.{crt,key} when present, so a config bundle works unchanged across machines and TW_CONFIG_DIR values — you rarely set the paths by hand.
Requires a recent Xray-core with mutual-TLS support
Presenting a client certificate on an outbound TLS connection is a recent Xray-core capability — older releases only wired server-side certificate selection, and the uTLS path used by XHTTP dropped the certificate fields entirely. Tunnel Whisperer therefore builds against an Xray-core version that includes native mutual-TLS support (the usage: "client-cert" certificate type, with the GetClientCertificate callback carried through to the uTLS path).
This is pinned in go.mod to the upstream commit that introduced mTLS and requires Go 1.26. No fork or patch is involved — the build is a plain go build. When upstream publishes mTLS in a tagged release, bump the github.com/xtls/xray-core version; the usage: "client-cert" config stays as-is.
Relay-side: the Caddy gate¶
The relay's Caddyfile is rendered by tw and installed at provisioning time (embedded in cloud-init or the manual install script) and re-rendered, validated, and gracefully reloaded on every enroll/un-enroll. The TLS block enforces the mTLS gate for the whole site; one handle block per enrolled server routes by certificate subject:
relay.example.com {
tls {
client_auth {
# verify_if_given: enrollees have no client cert yet, so the
# /enroll routes must admit a bare TLS handshake. Presented certs
# are still verified against the trust pool, and every tunnel
# route below requires a verified CN — no cert, no tunnel.
mode verify_if_given
trust_pool file /etc/caddy/ca/srv1-1a2b3c4d.crt /etc/caddy/ca/srv2-5e6f7a8b.crt
}
# TLS 1.3 only: a single arg to `protocols` sets the minimum, pinning 1.3.
protocols tls1.3
}
@srv1-1a2b3c4d {
path /tw/srv1-1a2b3c4d*
expression {http.request.tls.client.subject} == "CN=srv1-1a2b3c4d"
}
handle @srv1-1a2b3c4d {
reverse_proxy h2c://127.0.0.1:30000 {
flush_interval -1
stream_close_delay 5m
transport http {
versions h2c 1.1
keepalive 120s
keepalive_idle_conns 32
}
}
}
@enroll_srv1-1a2b3c4d path /enroll/srv1-1a2b3c4d/*
handle @enroll_srv1-1a2b3c4d {
reverse_proxy 127.0.0.1:40000
}
# ...one matcher + handle pair per enrolled server...
handle {
respond 404
}
handle_errors {
respond 404
}
}
mode verify_if_given— Caddy verifies a client certificate against the trust pool if one is presented, but a bare handshake with no certificate is allowed. This is what letstw joinreach/enroll/<tok>before it has a certificate to present.trust_pool file …— the public CA certificate of every enrolled server, written to/etc/caddy/ca/<server-id>.crton the relay.protocols tls1.3— TLS 1.3 only.@enroll_<server-id>matcher — routes a tenant's/enroll/<tok>/*path to its local invite listener (the porttw relay invite/tw server user inviteopens over the tenant's own reverse SSH tunnel), no certificate required — the one-time code and SAS read-back are the gate here, not TLS.@<server-id>matcher — path and client-certificate subject must both name the same server, isolating tenants from each other on a shared relay.stream_close_delay 5m— holds reverse-proxy streams open for up to five minutes across a Caddy reload so long-lived tunnels survive config changes (enrolling a new server reloads Caddy gracefully; the SSH auto-reconnect covers the rest).- Unmatched requests get a plain
404.
Two credential layers, side by side¶
| Client certificate (this page) | SSH key (Access Control) | |
|---|---|---|
| Scope | Per server — every certificate a server's CA issues carries the same CN, whether or not the keypair behind it is shared | Per user |
| Checked by | Relay's Caddy, at the TLS handshake | Server's embedded SSH server, after the tunnel is up |
| Purpose | Admit the connection to the relay and route it to the right server | Authenticate the user, restrict forwardable ports |
| Issued by | The server's CA (automatic, first profile init) | The server (tw server user invite) |
| Revocation | tw relay un-enroll-server (removes the CA from the trust pool) | Remove the key from authorized_keys (immediate — re-read every auth) |
To block a single user, remove their SSH key — the certificate layer is not involved. To cut off an entire server's relay slot, un-enroll it from the relay.
Relationship to the UUID check¶
The VLESS UUID is still present in the configuration and still matches at the relay's per-tenant Xray inbound, but it is now defense-in-depth, not the security boundary. Admission to a tunnel is decided by the certificate gate — at the handshake for an invalid certificate, at the route match for a missing or mismatched one; an attacker who cannot present a trusted, matching client certificate never reaches the point where a UUID would be checked.