Skip to content
← All posts

17 August 2026

Can a VPN work when the network only allows ports 80 and 443?

Yes, but only if the VPN is actually listening on 443. That is the whole answer. A lot of corporate, campus, hotel and mobile networks permit outbound TCP on 80 and 443 and quietly drop everything else, and most VPN protocols do not live there. WireGuard's default is 51820/udp. OpenVPN's is 1194. A proxy on 8443 is on the wrong side of the same wall. If your client sits at "connecting" forever on one network and works everywhere else, a port filter is the first thing to suspect, before you start blaming the app.

We just rebuilt our own transport layout around that, and this is what it involved — including the two things that broke while we did it.

What the network actually sees

A filter that only allows 80 and 443 is not clever. It does not need to identify your traffic; it just refuses to carry it. This is different from deep packet inspection, which looks at the shape of a connection it is carrying and decides whether the shape looks like a VPN. The two get conflated constantly in forum threads, and they need different answers. DPI wants your traffic to look like something ordinary. A port filter just wants your traffic to be on the port ordinary traffic uses.

Our answer to DPI is a set of transports that each impersonate something legitimate: VLESS + REALITY, which borrows a real site's TLS handshake, Cloak, which mimics TLS and sends probes to a decoy site, NaiveProxy, which uses genuine Chromium TLS and HTTP/2 so the fingerprint matches a browser, Shadowsocks, and Hysteria2 over QUIC. The client tries them in order until one connects.

But a fallback ladder is only as good as the rungs a given network can reach. Ours used to be laid out like this: Cloak on 443/tcp, REALITY on 8444/tcp, NaiveProxy on 8443/tcp, Hysteria2 on 443/udp. On a network allowing only 80 and 443 outbound, exactly one of those was reachable. Four transports on paper, one in practice.

There was a second problem, subtler and arguably worse. A host answering on 443 and 8443 and 8444 at once is an unusual fingerprint. An ordinary web server listens on 80 and 443. Anything else invites a second look from whoever is scanning.

Why three TLS transports cannot just share a port

The obvious fix is to put all of them on 443. The obvious fix does not work by ordinary means, because each of these transports needs to own the TLS handshake itself.

REALITY has to perform its own handshake, because that is the security model — it forwards clients that fail authentication to a real, unrelated website, so a prober who pokes at it gets that site's genuine certificate and no evidence anything else lives there. Terminating TLS in front of REALITY defeats the entire point of it.

NaiveProxy needs a real CA-issued certificate for its own domain, because it is trying to look like ordinary Chrome-to-website HTTPS, and ordinary HTTPS has a valid certificate.

Cloak does its own TLS mimicry and its own probe redirection to a decoy.

Three processes, each of which must be the first thing the client's TLS handshake touches. You cannot stack them.

Two designs, and why we picked the boring one

Option A: an SNI demultiplexer. One process owns 443, reads the unencrypted server name from the ClientHello, and forwards the connection untouched to the right backend. nginx's ssl_preread and HAProxy both do this well. It works. It also adds a userspace hop to every connection, loses the real client IP at the backend unless you bolt on PROXY protocol, and makes one process the single point of failure for every transport at once.

Option B: one public IP address per transport, each listening on 443. No demultiplexer, no extra hop, no client-IP loss, no shared process fate. And each address then serves exactly one consistent server name — which is precisely what an ordinary web server looks like from outside.

We shipped B. It is less clever and it costs a couple of extra addresses per node. Per node, the work is two extra IP addresses, one DNS record for the NaiveProxy domain, five environment variables, a control-panel restart, and updating the address values in the hub's node record. That is a small enough operation that we can do it consistently rather than heroically, which matters more than elegance.

The two things that broke

Worth writing down, because both are the kind of failure that looks like a mystery for an hour.

Cloak was bound to :443 — every address on the host, not one. Nothing else could bind 443 at all; the second process just died with EADDRINUSE. It had to be narrowed to an explicit address list before any of the rest was possible.

Hysteria2 was bound to 0.0.0.0:443/udp, and when Caddy moved onto 443 its HTTP/3 listener collided with it and took NaiveProxy down as collateral. The rule that falls out of this: with per-transport addresses, nothing should bind 0.0.0.0 for anything, ever. Be explicit or get surprised.

One good side effect. With Caddy owning 443 on its own address, certificate issuance moved from the HTTP-01 challenge to TLS-ALPN-01, which runs entirely over 443. That removes any requirement to expose port 80 at all — one fewer open port to explain.

Verifying it from outside

Claims like this are checkable, so we checked them from outside the network, address by address. REALITY's address presents cloudflare-dns.com's own genuine certificate. NaiveProxy's presents our Let's Encrypt certificate for its own domain. Cloak's presents www.microsoft.com. Probed with an unrelated server name, both the Cloak and REALITY addresses fall through to their real decoy sites, which is the behaviour a scanner is supposed to find.

You can run the same check on any host with openssl s_client -connect <address>:443 -servername <something-unrelated> and read what comes back.

What we have not fixed

The NaiveProxy address returns a TLS alert when it gets an unknown server name, rather than falling through to a real site the way the other two do. That is a mild fingerprint. It is on the list; it is not done.

Hysteria2 is still UDP. A network that blocks outbound UDP entirely still cannot reach it, and no amount of port arithmetic changes that.

And this does not mean we work on every network. Captive portals, whole-network shutdowns and a filter that blocks our hub's address directly are all still real. Anyone promising you a VPN that cannot be blocked is selling you something.

What changed for you

The public TCP surface per node is now 443 and 80, and on a network that allows only those two ports a client reaches three transports instead of one. If the first one is being interfered with, there are two more behind it on the same port the network already has to permit.

If this is the network you are stuck behind — halls wifi, a campus network, a hotel, a filtered office guest network — that is the specific problem we build for. There is a five-day free trial with no card, and the desktop client is GPLv3 on GitHub, so none of the above has to be taken on faith. See also our notes on VPNs for school and campus networks.

Pangea Development Team