TLS & uTLS
The encryption layer, and the fingerprint mimicry that makes your client look like Chrome instead of a Go program.
TLS encrypts the bytes your proxy sends, but it does not hide that you are running a proxy. A
censor cannot read a TLS payload — yet it can read the ClientHello you send in the clear, and the shape of that ClientHello can betray you as surely as any payload. Xray’s TLS layer is built
around that fact: it hands the real crypto to Go’s crypto/tls, then rewrites the handshake so it
looks like a browser instead of a Go program.
A stock Go TLS handshake is a fingerprint
Every TLS library writes its ClientHello in a slightly different order, and that order is a
fingerprint (JA3) a censor can match without decrypting anything. Go’s crypto/tls has a
distinctive one; a real Chrome does not look like it. Xray fixes this by routing the client
handshake through uTLS, which forges a
byte-for-byte copy of a chosen browser’s ClientHello.
UClient wraps the connection with a *utls.UConn built from a ClientHelloID — a recorded
browser fingerprint. The name comes straight from your config’s fingerprint field:
func GetFingerprint(name string) (fingerprint *utls.ClientHelloID) {
if name == "" {
return &utls.HelloChrome_Auto
}
// … otherwise look up "chrome", "firefox", "safari", "randomized", …
} Presets cover chrome, firefox, safari, ios, android, edge, 360, qq, plus randomized (a
freshly seeded ClientHello per startup) and random (one modern preset chosen at random when the
process boots, via a crypto/rand draw in init()).
ALPN and SNI are filled in for you unless you override them
Xray picks sane defaults for the two ClientHello fields that matter most — the protocols you offer (ALPN) and the hostname you claim (SNI) — so a misconfigured client still looks normal.
transport/internet/tls/config.goGetTLSConfig converts the proto config into a crypto/tls.Config. If you set no alpn, it
defaults to ["h2", "http/1.1"] — exactly what a browser offers. SNI comes from the dialed
destination unless serverName overrides it (the WithDestination option fills it from the dial
target only while it is still empty; GetTLSConfig then overwrites it with your serverName whenever that is non-empty):
{
"security": "tls",
"tlsSettings": {
"serverName": "www.cloudflare.com",
"alpn": ["h2", "http/1.1"],
"fingerprint": "chrome",
"minVersion": "1.3"
}
} One subtlety for WebSocket and HTTPUpgrade: those need http/1.1 only, so WebsocketHandshakeContext rewrites the uTLS ALPNExtension down to a single http/1.1 — unless ECH is active, in which
case the outer ALPN stays h2,http/1.1 and the real value hides inside the encrypted hello.
ECH encrypts the one field TLS still leaks
TLS 1.3 encrypts everything except the SNI — the hostname you are visiting travels in plaintext, and that is exactly what SNI-based blocking reads. Encrypted Client Hello (ECH) seals the real ClientHello, SNI included, inside an outer hello that carries only a public “cover” name.
transport/internet/tls/ech.goThe key that encrypts the inner hello lives in the domain’s DNS HTTPS record. ApplyECH either
decodes a base64 echConfigList directly or queries it over DoH/UDP (dnsQuery sends a type-65
question and pulls the SVCBECHConfig), caching results per domain.
Pin a certificate instead of trusting every CA on earth
The default TLS trust store believes any of hundreds of CAs — pinning tells the client to accept exactly one certificate or key, so a forged-but-valid cert from a compromised CA is rejected.
transport/internet/tls/pin.go transport/internet/tls/config.gopinnedPeerCertSha256 takes comma-separated SHA-256 hashes (OpenSSL colon format is stripped
automatically). Setting it flips InsecureSkipVerify on so Go’s built-in chain check steps aside,
and the always-installed verifyPeerCert callback takes over: verifyChain hashes the presented
leaf, short-circuits to success on a match, and — if the pin matches a CA further up the presented
chain instead — narrows the root pool to that one cert before verifying the chain against your serverName.
Disable verification with allowInsecure
allowInsecure: true turns off certificate checking entirely, so any MITM with any cert reads
your traffic. It is a removed feature — Xray refuses to build the config after 2026-06-01.
Pin the leaf or CA hash
"pinnedPeerCertSha256": "a1:b2:…" accepts a self-signed or private cert without trusting the
public CA system — you verify the exact key, not a chain anyone can forge.
For servers, the same file hot-reloads certificates and staples OCSP on a ticker, so a renewed Let’s Encrypt cert is picked up without a restart.