Contents

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.

transport/internet/tls/tls.go

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.go

GetTLSConfig 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.1unless 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.go
ECH: the client fetches a public key from the DNS HTTPS record, then the real SNI rides encrypted inside a cover ClientHello

The 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.go

pinnedPeerCertSha256 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.

Don't

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.

Do

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.