Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

One Hostname, One Port, a Different TLS Certificate per URL Path

TLS certificates are selected during the handshake. At that point the server has not seen a single byte of HTTP, so it cannot know the URL path. The only thing it has is SNI β€” the hostname. Certificates are chosen by hos

TLS certificates are selected during the handshake. At that point the server has not seen a single byte of HTTP, so it cannot know the URL path. The only thing it has is SNI β€” the hostname. Certificates are chosen by hostname, never by path. That's just how TLS works.

I decided I wanted it to work the other way anyway.

The result is Haruharu Cert Routing (HCRT): an experimental TLS 1.3 server written from scratch in a single Python file. Same hostname, same port 443, no redirects β€” but /a/* is served with certificate A and /b/* with certificate B. Open the padlock in your browser and you will see a genuinely different certificate, not a trick of the UI.

Live demo (two real Let's Encrypt certificates, both with CN sslgateway.haruharutv.jp, issued by different LE intermediates):

https://sslgateway.haruharutv.jp/

Follow the links between /a/cafe and /b/create. Each page prints the serial number of the certificate it was actually served with, at the bottom.

Source code (MIT): https://github.com/htvoffcial/haruharu-cert-routing

The basic idea

There is no way around the core problem: during the handshake, the path does not exist yet. Nothing in the ClientHello will ever tell you it.

But a browser session is not one connection. It can be two.

So the approach is: accept the first connection with a default certificate, look at the requested path, and if that path belongs to a different certificate, send nothing and make the browser come back. On the second connection, present the right certificate from the very start.

For this to work, two things have to hold:

  1. The server must recognize the second connection as "the same browser, continuing".
  2. The browser must retry without showing the user an error.

Both turned out to be solvable.

Part 1: the HTTP/2 rewind

The main mechanism runs on HTTP/2. I call it the ChronoCert rewind.

Session resumption tickets are opaque blobs that the server creates and the client stores. HCRT issues tickets in its own encrypted format, and inside each ticket it stamps a lineage ID β€” 8 bytes that stay constant across every ticket derived from the same original session. When a browser reconnects and presents a ticket, the server decrypts it and learns which lineage the connection belongs to.

Now the flow:

  • The browser connects (default certificate) and navigates to /b/create.
  • The server sees that /b/* belongs to certificate B, but this connection presented A.
  • The server records: "for this lineage, the pending target is certificate B" (kept for 30 seconds).
  • For that stream, the server sends RST_STREAM with REFUSED_STREAM, then a GOAWAY, waits 250 ms, and closes the connection. Not a single byte of the response is sent.

REFUSED_STREAM literally means "I refused this before processing it, it is safe to retry elsewhere". Browsers honor that: they quietly open a new connection and resend the request, without surfacing an error to the user.

On the new connection, the ClientHello carries the ticket. The server reads the lineage ID, finds the pending target, and this time presents certificate B in the handshake. The page loads, over HTTP/2, with the correct certificate β€” and the URL never changed.

One ordering detail mattered a lot in practice: the RST_STREAM has to reach the browser before the connection teardown does. If the close arrives first, some browsers treat it as a network error instead of a retry signal. Hence the 250 ms pause between GOAWAY and close.

Part 2: peeking at 0-RTT (HTTP/1.1)

For HTTP/1.1 there is a second, completely different mechanism.

With TLS 1.3 0-RTT, a resumed connection's first flight contains the actual HTTP request, encrypted under the resumption PSK. The server can decrypt that early data during the handshake, read the path, and β€” if the path belongs to a different certificate β€” refuse the PSK and fall back to a full handshake presenting the right certificate.

This works. I verified it end-to-end with openssl s_client: fresh connection gets A, resumed connection with early data for /b gets B, with early data for /a gets A.

The catch: Chrome never sends early data on top-level navigations. It presents the ticket (so resumption happens), but the early_data extension is simply not there. So in Chrome this mechanism never fires, and the HTTP/2 rewind does all the real work. I kept the 0-RTT path anyway β€” it is the "cleaner" of the two ideas, and it does work for clients that send early data.

Part 3: Safari, or how I learned tickets are optional

Then I opened the demo on an iPad.

Safari failed with "the network connection was lost". Digging through the server logs, the reason was almost funny: Safari never presented our session tickets at all. No pre_shared_key extension in its ClientHello, on any connection. No ticket means no lineage ID, which means every reconnect looked like a brand-new visitor, got the default certificate, got rewound, reconnected, got the default certificate again β€” an infinite loop until Safari gave up.

The fix is a fallback I am not particularly proud of: when a connection presents no ticket, the server also remembers pending targets by source IP for 10 seconds, and treats a reconnect from the same IP as the same lineage. With that, Safari switches certificates in both directions.

Let me be honest about the weakness, because it is real: IP-based identity is not identity. Another client behind the same NAT, or behind the same Private Relay egress, connecting within those 10 seconds could in principle pick up someone else's pending target. The ticket lineage is the actual mechanism; the IP fallback is a pragmatic shim for ticket-less browsers, with a short TTL to keep the blast radius small.

Running it

Configuration is a single small file:

listen [::]:443

host example.com {
  default certs/a.pem certs/a.key
  route /a/* certs/a.pem certs/a.key root sites/a-root
  route /b/* certs/b.pem certs/b.key root sites/b-root
}

Certificates are checked for changes every 20 seconds and hot-reloaded, so rotating a cert does not need a restart. The demo runs on a 512 MB LXC container.

To try it locally with self-signed certificates:

git clone https://github.com/htvoffcial/haruharu-cert-routing
cd haruharu-cert-routing/examples
./gen-certs.sh
cd .. && python3 hcrt.py examples/config.hcrt
# open https://localhost:8443 and wander between /a/ and /b/

Limitations, stated plainly

This is an experiment, and the scope of v1 is deliberately narrow:

  • TLS 1.3 only
  • One cipher suite: TLS_AES_128_GCM_SHA256
  • Key exchange: X25519 only
  • Certificates: ECDSA P-256 only
  • Static file serving only (GET/HEAD), no proxying, no CGI, nothing dynamic

Verified so far: Chrome (the full /a β†’ /b β†’ /a β†’ /b round trip, no redirects, no certificate errors) and Safari on iPad (after the IP fallback). During testing, Chrome once flashed an ERR_HTTP2_SERVER_REFUSED_STREAM page for a split second before recovering on its own β€” it has not been perfectly invisible in every single run, and I'd rather say that here than pretend otherwise. Firefox has not been tested yet.

Please do not put this in front of anything that matters.

Why build this at all

There is no practical use case. Nobody needs per-path certificates.

But the constraint "the path does not exist at handshake time" felt absolute in a way that turned out to be negotiable, once I started thinking about connections as a sequence instead of a single event. The specs involved β€” TLS 1.3 resumption, RFC 9113's REFUSED_STREAM semantics β€” were all designed for other purposes. The interesting part was finding that they compose into something none of them individually intended.

If you try it and find a browser that behaves differently, I would genuinely like to hear about it.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.