
Some bugs announce themselves loudly. Others lurk in the shadows, appearing only under the exact right — or rather, wrong — constellation of conditions. This is a story about one of the latter: an SSL anomaly that seemed almost supernatural, until we traced it back to the quiet revolution happening beneath the web's transport layer.
The Symptom: A Certificate That Doesn't Belong
Picture this scenario. You manage a server — a single machine with a single public IP address — hosting multiple websites. This is a perfectly standard setup. Nginx handles virtual hosting, routing incoming requests to the correct site based on the domain name in each request. Site A has its own dedicated SSL certificate. Site B has a wildcard certificate covering *.site-b.com.
This configuration has been running flawlessly for years. Then, one day, a user reports something bizarre:
<> "I tried to open Site A on my iPhone in Chrome, and it gave me a security warning saying the certificate belongs to Site B."/>
You check the server. The certificates are correct. The Nginx configs are correct. DNS is correct. You open Site A on your desktop — works fine. You open it in Safari on the same iPhone — works fine. You reload the page in Chrome on the iPhone — and it works fine too.
The error is intermittent, unreproducible on demand, and seems to affect only mobile Chrome. A ghost in the machine.
Why SNI Should Prevent This
To understand why this bug is so disorienting, you need to understand how multi-site HTTPS hosting is supposed to work.
When a browser connects to an HTTPS server, the very first thing that happens — before any HTTP request is sent — is a TLS handshake. During this handshake, the server needs to present the correct SSL certificate. But here's the problem: the server doesn't yet know which website the browser wants. The HTTP Host header hasn't been sent yet — TLS happens first.
This is where SNI (Server Name Indication) comes in. SNI is an extension to the TLS protocol that allows the browser to include the requested hostname in the very first message of the TLS handshake (the ClientHello). The server reads this hostname, looks up the corresponding virtual host configuration, and presents the correct certificate.
SNI has been universally supported for over a decade. Every modern browser, every modern server. It just works.
So how could Nginx possibly serve the wrong certificate? The browser sends the hostname, Nginx reads the hostname, Nginx picks the right config block. It's mechanical, deterministic. There's no room for confusion.
Unless the request never goes through the normal TLS handshake at all.
The Clue: It's Only Mobile Chrome
The pattern was the key. The bug appeared:
- On mobile devices (primarily iPhones on cellular networks)
- Only in Google Chrome
- Never in Safari, Firefox, or desktop Chrome
- Intermittently — sometimes it worked, sometimes it didn't
What's special about Chrome on mobile? Chrome is Google's browser, and Google has been the driving force behind a protocol that fundamentally changes how browsers talk to servers: QUIC, also known as HTTP/3.
A Quick Primer on HTTP/3 and QUIC
The internet has been built on TCP (Transmission Control Protocol) for decades. Every HTTP request — HTTP/1.0, HTTP/1.1, HTTP/2 — travels over TCP. TCP is reliable: it guarantees that packets arrive in order, retransmits lost packets, and manages congestion. But reliability comes at a cost: latency.
Every TCP connection starts with a three-way handshake (SYN, SYN-ACK, ACK). For HTTPS, you then add a TLS handshake on top of that. Before a single byte of your web page is transferred, you've already done 2-3 round trips between the browser and server. On a mobile network with 100ms latency, that's 200-300ms of pure waiting.
HTTP/3 changes the game by replacing TCP with QUIC (Quick UDP Internet Connections). QUIC runs over UDP instead of TCP. UDP is a much simpler protocol — it just fires packets without the overhead of connection setup, ordering guarantees, or congestion control. QUIC builds its own reliability and encryption directly into the protocol, combining the transport handshake and TLS handshake into a single round trip. On subsequent connections, it can even achieve zero round-trip connection resumption (0-RTT).
This is a massive win for mobile users, who often deal with high-latency, lossy cellular connections. That's exactly why Chrome is so aggressive about using QUIC whenever possible.
How Chrome Discovers QUIC Support
A server advertises QUIC support through a response header:
1Alt-Svc: h3=":443"; ma=86400This tells the browser: "Hey, I also speak HTTP/3 on UDP port 443. Remember this for the next 24 hours."
Chrome caches this information. The next time you visit the same server, Chrome will attempt to connect via QUIC (UDP) first, before falling back to TCP. Importantly, this cache is keyed by IP address, not by domain name.
Let that sink in. Chrome remembers that a particular IP address supports QUIC, regardless of which domain originally told it so.
Connecting the Dots
Now the pieces fall into place. Here's what was happening on our server:
The Server Configuration
Site B's Nginx config had been updated to support HTTP/3:
1# Site B - modern config with HTTP/3
2server {
3 listen 443 ssl;
4 listen 443 quic;
5 server_name site-b.com *.site-b.com;
6
7 ssl_certificate /etc/ssl/site-b/fullchain.pem;
8 ssl_certificate_key /etc/ssl/site-b/privkey.pem;
9
10 add_header Alt-Svc 'h3=":443"; ma=86400' always;
11
12 # ... rest of config
13}Site A had the traditional config — TCP only:
1# Site A - traditional config, TCP only
2server {
3 listen 443 ssl;
4 server_name site-a.com;
5
6 ssl_certificate /etc/ssl/site-a/fullchain.pem;
7 ssl_certificate_key /etc/ssl/site-a/privkey.pem;
8
9 # ... rest of config
10}The Attack Sequence
Here's the chain of events that led to the phantom certificate:
- The user visits Site B on their phone in Chrome. Everything works perfectly. Site B responds with the
Alt-Svc: h3=":443"header. - Chrome caches this information: "The server at IP address
203.0.113.42supports QUIC on port 443." Note: it remembers the IP, not just the domain. - Later, the user visits Site A. Site A resolves to the same IP address (
203.0.113.42) because both sites are on the same server. - Chrome checks its QUIC cache. It finds an entry for
203.0.113.42:443— QUIC is supported! Chrome decides to be clever and sends the request via UDP instead of TCP. - The UDP packet arrives at Nginx on port 443. Nginx looks at its configuration to determine which server block should handle this QUIC connection.
- The critical moment. Nginx examines its
listendirectives:
- Site A:
listen 443 ssl;— TCP only. This server block does not accept UDP/QUIC connections. - Site B:
listen 443 quic;— TCP and UDP. This server block accepts QUIC connections.
- Nginx has no choice. Since Site A doesn't listen for QUIC, and Site B is the only server block that accepts UDP traffic on port 443, Nginx routes the QUIC connection to Site B's server block.
- Site B responds with its own SSL certificate — the wildcard cert for
*.site-b.com. - Chrome sees a certificate mismatch: the user requested
site-a.com, but the server presented a certificate for*.site-b.com. Chrome displays the dreaded SSL error.
Why It Seemed Intermittent
The bug's intermittent nature made it especially maddening:
- It only happened when Chrome's QUIC cache had an entry for the server's IP. If the cache expired (after the
matimeout, typically 24 hours) or was cleared, Chrome would fall back to TCP, and everything would work. - Safari doesn't use QUIC as aggressively (or at all, in some versions), so it always used TCP and always worked.
- Desktop Chrome might have a separate QUIC cache or different Alt-Svc discovery behavior, making the bug harder to reproduce.
- Reloading the page sometimes worked because Chrome's QUIC fallback mechanism kicked in after the first failed attempt — the browser retried over TCP.
The Fix: Teaching All Sites to Speak QUIC
The solution is conceptually simple: if any site on a shared IP address advertises QUIC support, every site on that IP must be configured to accept QUIC connections.
Here's the corrected configuration for Site A:
1# Site A - updated config with HTTP/3 support
2server {
3 listen 443 ssl;
4 listen 443 quic;
5 server_name site-a.com;
6
7 ssl_certificate /etc/ssl/site-a/fullchain.pem;
8 ssl_certificate_key /etc/ssl/site-a/privkey.pem;Now when Chrome sends a QUIC request for site-a.com, Nginx can match it against Site A's server block (which now accepts QUIC), read the SNI hostname, and serve the correct certificate.
The reuseport Trap
There's an important gotcha when adding QUIC support to multiple server blocks: the reuseport parameter.
When you first set up QUIC, many guides tell you to add reuseport to improve performance:
1listen 443 quic reuseport;The reuseport option tells the kernel to create a separate socket for each worker process, allowing them to accept connections in parallel without lock contention. It's a performance optimization.
However, reuseport can only be specified once per port across all server blocks. If you try to add listen 443 quic reuseport; to both Site A and Site B, Nginx will refuse to start with an error like:
1nginx: [emerg] duplicate listen options for [::]:443 in /etc/nginx/sites-enabled/site-a.conf:3The solution is to use reuseport in only one server block (typically the first one Nginx loads, or a dedicated default block) and omit it from the others:
1# Site A (loaded first alphabetically)
2server {
3 listen 443 ssl;
4 listen 443 quic reuseport; # reuseport only here
5 server_name site-a.com;
6 # ...
7}
8
9# Site B
10server {
11 listen 443 ssl;
12 listen 443 quic; # no reuseport
13 server_name site-b.com *.site-b.com;
14 # ...
15}Alternatively, you can create a dedicated default server block that owns the reuseport and rejects unmatched requests:
1# Default catch-all (prevents wrong certificate serving)
2server {
3 listen 443 ssl default_server;
4 listen 443 quic default_server reuseport;
5 server_name _;
6
7 ssl_certificate /etc/ssl/default/fullchain.pem;
8 ssl_certificate_key /etc/ssl/default/privkey.pem;
9
10 # Reject all requests to unknown hosts
11 return 444;
12}This catch-all block ensures that if a QUIC request arrives for a hostname that somehow isn't configured, Nginx drops the connection instead of leaking another site's certificate.
The Complete Checklist
If you're running multiple HTTPS sites on a single IP with Nginx, here's what you need to verify:
- If ANY site has
listen 443 quic, add it to ALL sites on the same IP. A site that only listens on TCP will be invisible to QUIC requests, causing Nginx to fall back to whichever server block does accept QUIC. - Use
reuseportexactly once per port. Put it on one server block (preferably a default catch-all) and omit it from all others. - Add
Alt-Svcheaders to all sites that support HTTP/3, so browsers know to use QUIC for those specific domains.
1add_header Alt-Svc 'h3=":443"; ma=86400' always;- Consider a default catch-all that returns
444for unmatched hostnames. This prevents certificate leakage if a new site is added but not yet configured for QUIC. - Set appropriate
ssl_protocols— HTTP/3 uses TLS 1.3 exclusively.
1ssl_protocols TLSv1.2 TLSv1.3;- Test from mobile Chrome on cellular — this is where QUIC usage is most aggressive. Use Chrome's internal page
chrome://net-internals/#quicto inspect QUIC connection state.
Verifying the Fix
After applying the configuration, you can verify that QUIC is working correctly for all sites:
1# Test QUIC connectivity using curl (requires HTTP/3 support)
2curl --http3 -I https://site-a.com
3curl --http3 -I https://site-b.com
4
5# Check Nginx configuration validity
6nginx -t
7
8# Verify which server blocks listen for QUIC
9nginx -T | grep -A2 'listen.*quic'You can also use online tools like Qualys SSL Labs or http3check.net to verify that HTTP/3 is properly configured for each domain.
The Bigger Lesson
This bug reveals a fundamental shift in how web servers need to be configured. For twenty years, web servers dealt exclusively with TCP. Virtual hosting, SNI, certificate selection — everything was designed around TCP's connection model.
HTTP/3 and QUIC introduce a parallel transport layer that runs over UDP. This means every configuration assumption that implicitly relied on "all traffic arrives via TCP" needs to be revisited. Nginx's server block matching, which worked perfectly in a TCP-only world, can produce unexpected results when some blocks speak UDP and others don't.
The transition from TCP to UDP-based protocols is one of the most significant changes in internet infrastructure in decades. It's happening gradually, driven by performance needs (especially on mobile), and most of the time it's invisible. But when it creates bugs like this one — bugs that seem to violate the fundamental rules of how HTTPS works — it's a reminder that the ground beneath our servers is shifting.
The fix is simple. The lesson is not: in a world where protocols are evolving, consistency across your server configurations isn't optional — it's a security requirement.

