An internal service is served with a certificate from the company’s own CA. The root was installed on every workstation by configuration management, with the documented command, and update-ca-certificates reported success. curl works. openssl s_client verifies the chain. Chrome shows NET::ERR_CERT_AUTHORITY_INVALID, and Firefox on the same machine gives a third answer that depends on which distribution it is. Nothing is broken: “the system trust store” has not been a single thing on Linux for years, and the command that appears to configure it configures the smallest share of what you actually use. That is one of the places where the standard answer to the oldest interview question in web engineering — what happens when you type a URL — has quietly gone wrong. The canonical write-up still treats SPDY, a protocol Chrome removed in 2016, as the alternative to HTTP/1.1, and its account of revocation describes a mechanism that has since been replaced rather than adjusted. Almost everything downstream of it inherits those errors.
This page walks the path as it works in September 2026, in six stages, on a Linux machine. Name resolution has its own page, and this one says only what that lookup hands back; the packet’s journey through the kernel has its own page too, so ARP, routing and queueing are one sentence rather than a section; and what a certificate proves is the introduction that stage 5 goes underneath. Rendering is not here either: parsing HTML, layout and paint are identical on every operating system and nothing you can configure on a Linux box changes them. What is Linux-specific is the process that does the rendering — how it is sandboxed, which security boundary your distribution has already broken, which display server it talks to — and that gets a section of its own at the end.
The six stages, and one command that halves them
- Before you press Enter. The browser may already have fetched the page.
- The URL is parsed — by a state machine that deliberately disagrees with RFC 3986.
- The upgrades that happen before any packet. Four separate mechanisms can turn your
http://intohttps://without touching the network. - A connection is raced into existence. Not just IPv4 against IPv6 — protocols against each other.
- The handshake, and who you trust. The densest stage, and the one that has changed most.
- The exchange, and the dozen caches. A request can be answered from any of about twelve stores before a socket opens.
Now the split. Type http://example.com — without the s — with this running:
sudo tcpdump -n -i any 'tcp port 80'Zero packets on port 80 means the upgrade to HTTPS was the browser’s own decision, taken in stage 3, before any network I/O at all. A GET / HTTP/1.1 followed by a 301 or 308 means it was the server’s decision, and it belongs to stage 6.
This is not a rule of thumb. HSTS is defined in RFC 6797 as a rewrite the user agent performs on the URI before the request is made, so it is incapable of producing a packet on port 80. A server redirect is incapable of existing without one. The two halves are distinguishable by construction, and no configuration can make them overlap.
One useful consequence straight away: if you see the redirect, then any Strict-Transport-Security header on that response is being set, not used. It will affect your next visit, not this one.
Stage 1 — Before you press Enter
Every article on this subject opens with the same sentence: you press Enter, and the browser now has a URL. For a meaningful share of navigations that sentence is false, because the fetch already happened while you were still typing.
Chrome’s omnibox sends what you type to the default search engine for suggestions, and the response can carry instructions back: one index in the payload marks a result for prefetch, another for prerender. Prefetch issues the request immediately, tagged Purpose: Prefetch; prerender builds the page in a hidden renderer process. Two details about the prefetched response contradict what stage 6 will say about caching: it is held in memory for sixty seconds, and when served it ignores the response’s cache headers entirely. It is not in the HTTP cache and not subject to Cache-Control.
The other decision in this stage is whether what you typed is a search or a navigation. Broadly, a single word goes to the search engine and a string with dots in it is treated as a hostname; the exact rules are not published in any current specification, so treat anyone who states them confidently with suspicion. And the long list of conditions that suppress prefetch means you cannot predict from the outside whether it happened. You can only observe it.
Stage 2 — The URL is parsed
The corpus describes this as splitting a string on :// and /. What actually runs is a state machine with roughly thirty states, defined by the WHATWG URL Standard, and it is not RFC 3986 and was never meant to be. It was written to describe what browsers already did with real-world input, and it diverges from the RFC in several places that produce different results for the same string:
- Backslashes are treated as path separators in special schemes, which the RFC does not do.
- Trailing dots in hostnames are handled by the host parser rather than passed through.
- Empty ports — a bare colon before the path — are normalised away.
- Percent-encoding sets differ per component, so the same byte is escaped in a query and not in a path.
These are exactly the divergences that turn up in URL-parsing security bugs, where a proxy validates a string with one parser and a browser fetches it with the other. If you are writing anything that inspects URLs before a browser does, the two parsers not agreeing is the vulnerability class.
For international domain names, the correct reference is UTS #46 ToASCII, not “punycode”: punycode is one step inside it, and describing it as the whole process skips the mapping, normalisation and validity checks where the interesting failures live. Whether a browser displays the Unicode form or the xn-- form is a separate spoof-detection heuristic, not part of the URL standard at all.
Stage 3 — The upgrades that happen before any packet
You typed http://. The browser is going to use HTTPS. Every article says HSTS did that. In 2026, HSTS is the least likely of four mechanisms to be the one that fired.
Here they are in the order they are actually consulted:
- The preload list, compiled into the browser binary. Consulted before DNS.
- The profile’s dynamic HSTS store — hosts that have sent you a
Strict-Transport-Securityheader before. Also before DNS. - Unconditional upgrading. Chrome’s HTTPS-Upgrades reached all Stable users on 16 October 2023 and upgrades every main-frame navigation with a fast fallback to HTTP. Firefox shipped the equivalent, HTTPS-First, as a default for everyone in Firefox 136, on 4 March 2025; Mozilla measured it upgrading 57% of upgradable content. Also before DNS.
- An HTTPS DNS record. Since Chrome 102, the mere existence of one causes Chrome to connect over HTTPS, modelled internally as a 307. This is the only one of the four that happens after the lookup.
So what does HSTS still uniquely do? Two things. It makes the upgrade mandatory, with no silent fallback to plaintext when HTTPS fails, and it removes the click-through on certificate errors. Neither of the unconditional mechanisms does either. “HSTS is how http:// becomes https://” is a much larger claim than the truth, and the truth is more useful.
The preload list is a build artefact
This surprises people who assume it updates like a blocklist. It does not. The list lives in transport_security_state_static.json in the Chromium source tree, is regenerated once per Chromium release just before the branch point, and is compiled into the binary. It is not a component update and it is not fetched.
The consequences are all downstream of that one fact, and the corpus states none of them:
- Getting on the list takes months to reach users, because it takes a Chrome release to reach them.
- Getting off it takes months too, and the site’s own documentation says removal cannot easily be undone and makes no guarantees about other browsers.
- Entries added by bulk scanning can be pruned automatically. Manually submitted entries cannot — a human has to do it.
- The maintainers’ stated constraint is download bandwidth: every byte added to the binary is multiplied by the number of Chrome installs on every future release.
The seam: what the name lookup hands back. Between stages 3 and 4 the name gets resolved, and that path has its own eight stages. What matters here is that a modern browser asks for A and AAAA records and the HTTPS record together, and the third carries three parameters that each drive a different stage: alpn, which protocols the origin speaks, drives stage 4 (with h3 in there, the first packet can be QUIC); ipv4hint and ipv6hint are addresses usable if the A and AAAA answers are slow, also stage 4; ech is the key material for Encrypted Client Hello, stage 5; and its mere existence drives stage 3, per the upgrade mechanism above. Which is why a resolver that strips the record, or a middlebox that mangles it, degrades three stages at once and looks like none of them.
Stage 4 — A connection is raced into existence
The classic answer says: open a TCP connection, three-way handshake, done. What actually happens is a race, and the entrants are no longer just IPv4 against IPv6.
Happy Eyeballs v2 is RFC 8305: start the connection to the first address family, and if it has not answered within a short delay, start the other and take whichever completes first. Version 3 is still a draft, in IESG evaluation as of July 2026, so anyone citing an RFC number for it is citing one that does not exist.
What v3 adds is what has made this stage bigger than address families: the HTTPS record is queried alongside A and AAAA, candidate endpoints are prioritised by which protocols they advertise and whether ECH is available, and the address hints are used when the ordinary answers are slow. Modern connection racing races protocols.
The trap here is a 2020-era fact that has expired. It used to be true that HTTP/3 required a prior TCP connection so the server could advertise Alt-Svc. With an HTTPS record carrying alpn="h3", it does not: the first packet on the wire can be a QUIC Initial and TCP is never touched. Firefox has used the record’s alpn since version 92, and Chrome uses it too.
The round trips, counted properly
| Path | Round trips before the first request byte |
|---|---|
| TCP + TLS 1.3 | 2 — one for the TCP handshake, one for TLS |
| TCP + TLS 1.2 | 3 — TLS 1.2 needs two, which is where the old figure comes from |
| QUIC + HTTP/3 | 1 — the crypto handshake is carried inside the transport handshake |
TCP Fast Open does not rescue the TCP column: it never became the default path for browser traffic, and Firefox removed its support in version 87. And there is a structural point in that table: for HTTP/3, stages 4 and 5 are the same round trip, because QUIC carries the TLS 1.3 handshake inside its own Initial packets. Splitting them, as this article does, is a TCP artefact.
Testing it, which requires a caveat first
curl -V | grep -o HTTP3 # nothing? your curl cannot do this
curl -sv --http1.1 https://example.com -o /dev/null
curl -sv --http3-only https://example.com -o /dev/nullThat first line matters more than it looks. “You have to build curl yourself for HTTP/3” was true in 2023 and is false now: Debian has shipped an HTTP/3-capable curl since Debian 13, with backports for bookworm since August 2024. Run the command rather than reasoning about it.
The two curl lines then give you a second clean split. QUIC and TCP+TLS present the same certificate and run the same TLS 1.3 handshake; the only difference is the transport carrying it. So if --http1.1 succeeds and --http3-only fails, the fault cannot be the certificate, the trust store or the TLS layer. It is UDP/443 being blocked on the path, or the server not offering h3 at all.
On the server side, the fact that dates a 2023 article fastest: you no longer need a patched TLS library to serve HTTP/3. nginx has had QUIC since 1.25.0, in the official Linux packages, with OpenSSL 3.5.1 and later as a supported provider; below 3.5.1 it falls back to a compatibility layer without 0-RTT, so ssl_early_data on silently does nothing there. Apache httpd still has no HTTP/3 at all: as of 2.4.68 it ships mod_h2 and nothing else, and any tutorial telling you to load mod_http3 is describing a module that does not exist.
Stage 5 — The handshake, and who you trust
A detail that catches people reading packet captures: in TLS 1.3 the version is negotiated in the supported_versions extension, and the legacy version field in the ClientHello is pinned at 1.2 so that middleboxes do not choke. A capture showing “TLS 1.2” in the record header is not evidence of anything.
Post-quantum key exchange is now the majority case
A 2023 article files this under “coming soon”. By early December 2025, 52% of human-generated web traffic reaching Cloudflare was using post-quantum encryption, up from 29% at the start of that year. Those are Cloudflare’s own figures with their date attached; treat any 2026 number you see without a source as invented.
Related, and neater than it sounds: TLS 1.2 is not deprecated — it is frozen, formally, as of RFC 9851 in July 2026. The working group will approve no changes to it beyond urgent security fixes, which means post-quantum key exchange will never be standardised for TLS 1.2. It is not being switched off. It is being left behind.
Encrypted Client Hello is a real RFC now
ECH encrypts the server name so that an observer cannot see which site you are visiting from the handshake. It stopped being a draft on 3 March 2026, when it was published as RFC 9849, with its DNS bootstrap in RFC 9848.
Deployment is real but not a straight line: Cloudflare enabled it in September 2023, disabled it globally that November, and re-enabled it in stages. The two conditions that decide whether you get it are the ones people miss, because they are not about TLS at all:
- Chrome will not use ECH if the resolver strips the
echparameter from the HTTPS record. - Firefox will not use it if the canary domain
use-application-dns.netresolves, which is how a network signals that it wants to intercept DNS.
Which is to say: ECH is gated on getting an unmolested HTTPS record, which in practice means encrypted DNS. The privacy property in stage 5 depends on a decision made in a stage this article delegates entirely.
Revocation: the mechanism was replaced, not adjusted
This is the largest change in this article’s subject since 2023. The old sentence — the browser checks the certificate’s revocation status with OCSP, ideally via stapling — is now wrong in both halves, for both browsers.
| When | What happened |
|---|---|
| 14 July 2023 | The CA/Browser Forum made OCSP optional for CAs and CRLs mandatory (ballot SC-063v4) |
| 30 January 2025 | Let’s Encrypt began failing requests for OCSP Must-Staple certificates |
| 7 May 2025 | Let’s Encrypt stopped putting OCSP URLs in certificates at all |
| 6 August 2025 | Let’s Encrypt switched the OCSP responders off |
| Firefox 137 | CRLite replaced live OCSP checking |
| Firefox 142 | OCSP disabled entirely for domain-validated certificates |
What replaced it: Firefox’s CRLite encodes the set of every certificate that appears as revoked in the Certificate Transparency logs, in a set-membership structure compact enough to ship — roughly 300 kB of revocation data per day, a full snapshot of about 4 MB every 45 days, updating every twelve hours. Chrome’s equivalent, CRLSets, is a smaller curated push. So revocation checking is a push model in both browsers, a set the browser already has when your handshake begins, not a query made during it. That is a different mechanism, not a degraded one.
Two more things that moved
Certificate Transparency is enforced by Firefox now. Since Firefox 135, on 4 February 2025, on desktop, a certificate chaining to a root in Mozilla’s program must present two signed certificate timestamps or the connection fails with MOZILLA_PKIX_ERROR_INSUFFICIENT_CERTIFICATE_TRANSPARENCY. Certificates chaining to roots you added yourself are exempt, which is exactly right, since a corporate MITM root could not log to a public CT log.
Certificate lifetimes are on a published shrinking schedule, and the first step has already happened. Under CA/Browser Forum ballot SC-081v3, adopted April 2025:
| From | Maximum certificate lifetime |
|---|---|
| 15 March 2026 | 200 days — this is the limit today |
| 15 March 2027 | 100 days |
| 15 March 2029 | 47 days |
And the part that is specifically about your machine
Here is that claim, and it is worth checking on your own box before you argue with it: Chrome and Firefox on Linux use the system CA store in /etc/ssl/certs. Neither of them does, and they are wrong in different ways.
Chrome has used its own root store on Linux since Chrome 114, and its verifier does not rely on the platform’s trust store. So running update-ca-certificates and watching it succeed tells you nothing about whether Chrome will trust the CA you just added. What Chrome does read for locally-added trust is the NSS shared database, and the path moved: since M146 the default is $HOME/.local/share/pki/nssdb, with the old $HOME/.pki/nssdb used only if it already exists. Effectively every tutorial online still gives the old path.
Firefox does not use the operating system’s trust store on Linux at all, and cannot be made to with the preference everybody reaches for: security.enterprise_roots.enabled is a Windows and macOS feature. The documented route on Linux is to load p11-kit-trust.so as a PKCS#11 security device.
Which is why the answer differs by distribution. Fedora wires libnssckbi.so through the alternatives system to p11-kit-trust.so, so Firefox on Fedora does see the system trust store; Debian has an open bug asking for the same thing and does not do it by default. Everything between those two documented poles would be guesswork, so check your own machine instead:
ls -l /etc/alternatives/libnssckbi.so
# points at p11-kit-trust.so -> Firefox sees the system store
# points at the real libnssckbi -> it does notThe split inside stage 5: same handshake, different trust anchors
openssl s_client -connect example.com:443 -servername example.com \
-verify_return_error < /dev/nullopenssl s_client and your browser run the same TLS 1.3 handshake over the same wire bytes, build the chain by the same rules, and match the hostname the same way. The only input that differs is the set of trust anchors. So a disagreement between them is not evidence about TLS at all — it is a measurement of a configuration difference, and the direction names the store.
- Both fail — the fault is on the wire. Expired certificate, wrong name in the SAN, an incomplete chain being served, a protocol or group mismatch, SNI not being sent. Trust stores are irrelevant.
opensslpasses, the browser fails — the certificate is valid against/etc/ssl/certs, so the certificate is fine. The browser is using a different set of roots. The usual cause is a root installed withupdate-ca-certificates, which Chrome has not read since 114.- The browser passes,
opensslfails — the inverse. A root in the browser’s bundled store that is missing from, or distrusted in, the system store.
Add curl as a third arm to remove the last ambiguity: on most distributions it links against the system store, so curl agreeing with openssl and both disagreeing with the browser localises the fault to the browser’s own roots with nothing left to guess.
Stage 6 — The exchange, and the dozen caches
Cite the right documents. HTTP is specified by RFC 9110 through 9114. The 723x series was obsoleted in June 2022, and RFC 2616, still cited constantly, was obsoleted in 2014; a page citing it for anything has HTTP content at least twelve years old.
HTTP/2 server push is gone. Chrome 106 disabled it by default, on measured usage under one per cent of HTTP/2 sites; the sanctioned replacements are 103 Early Hints and <link rel=preload>, and HTTP/3 never had push in browsers at all. HTTP/1.1 pipelining has been dead for a decade and is likewise still in the corpus.
“The browser checks its cache” — which one?
Every article says the browser checks the cache, singular, before doing anything else. There are about a dozen stores that can answer a request before a socket opens, they have different keys and different lifetimes, and two of them are not caches at all but get confused for them.
| Store | Keyed by | What makes it distinct |
|---|---|---|
| HTTP disk cache | Partitioned: network isolation key + URL | Not the URL alone — see below |
| In-memory resource cache | Per document | Not the disk cache |
| Omnibox prefetch store | Search term | 60 seconds, ignores cache headers |
| Back/forward cache | History entry | Holds a live, frozen document, not bytes |
Service Worker CacheStorage | Script-controlled | Intercepts before the HTTP cache |
| Preload and prefetch caches | Speculation | Separate again from both of the above |
| V8 code cache | Script URL | Compiled bytecode, not response bytes |
| Host resolver cache | Hostname | One of several DNS caches on the path |
| Socket and connection pool | Origin, plus isolation key | Reuse, not caching — but confused for it constantly |
| TLS PSK ticket store | Server | Resumption material |
| Alt-Svc / HTTP/3 store | Origin | Remembers that an origin speaks h3 |
| Dynamic HSTS store | Host | Not a cache, but consulted at the same instant |
The HTTP cache is partitioned. Since Chrome 86 the key includes a network isolation key derived from the top-level site, and Firefox partitions network state similarly, so two different pages loading the same CDN URL do not share a cache entry. That quietly killed the performance argument for putting a shared JavaScript library on a public CDN, which is still recommended in a great deal of writing.
A service worker intercepts before the HTTP cache. For any installed progressive web app, a page can be served with the network stack never consulted at all — no cache lookup, no resolution, no connection. Which means “check the cache, then do DNS” is not merely imprecise; it is the wrong order.
What the browser actually is on your machine
This is not stage 7. The six stages describe how a request becomes a response; this section describes the thing that did it, and it is the part of the story that is genuinely different on Linux.
pstree -p $(pgrep -o chrome) # one browser, many processes
ls -l /proc/PID/ns # which namespaces it is in
grep Seccomp /proc/PID/status # 2 means filter mode
echo $XDG_SESSION_TYPE # wayland or x11A browser is not a process. Each renderer, the GPU process and the network service run separately, and the renderers, which execute untrusted code, get confined in two layers: layer 1 restricts what the process can name and reach, historically a setuid helper and now normally a user namespace, and layer 2 reduces the kernel surface it can call, using seccomp-BPF.
Ubuntu restricts unprivileged user namespaces by AppArmor policy, on the reasoning that they have been a productive source of kernel privilege escalations. Chromium’s sandbox wants exactly that capability, so the browser falls back to the setuid binary, a mechanism from 2012 that its own documentation calls deprecated, and the visible symptom is an error about a SUID sandbox helper that has generated bug reports continuously since Ubuntu 24.04. Two defensible security policies have reached opposite conclusions about the same kernel feature. The two sysctls involved:
sysctl kernel.apparmor_restrict_unprivileged_userns
sysctl kernel.apparmor_restrict_unprivileged_unconfinedOn display: Chrome 140 changed the default from X11 to automatic detection, so on a Wayland session it now uses Wayland natively rather than going through XWayland; the motivating bug was blurry text under fractional scaling. If you have an --ozone-platform-hint=auto flag in a desktop file or a wrapper script, it is now redundant.
Two internal pages verify the rest: chrome://sandbox lists the renderer processes and the confinement actually applied to each, and chrome://gpu reports what the GPU process negotiated.
A worked diagnosis
An internal service is served with a certificate from the company’s own CA. The root was installed on every workstation by configuration management, using the documented, correct command. It works from the command line. Chrome shows NET::ERR_CERT_AUTHORITY_INVALID. The reporter’s conclusion is that Chrome is broken.
$ curl -sI https://internal.example.com | head -1
HTTP/2 200
$ openssl s_client -connect internal.example.com:443 \
-servername internal.example.com -verify_return_error < /dev/null 2>&1 \
| grep -E 'Verify return code|Protocol'
Protocol : TLSv1.3
Verify return code: 0 (ok)Two of the three agree, and they are the two that read /etc/ssl/certs. By the split earlier in stage 5, that settles it: the handshake is fine, the chain is fine, the hostname matches, the certificate is valid. The only remaining variable is the trust anchor set, and the direction of the disagreement names the store — the browser’s own.
Which is not a bug. Chrome has used the Chrome Root Store on Linux since version 114, and its verifier does not consult the platform trust store at all. update-ca-certificates succeeded, updated the file it was supposed to update, and was never going to affect Chrome. The fix is to put the root where Chrome does look:
# Chrome reads the NSS shared DB. Note the path, which moved in M146.
certutil -d sql:$HOME/.local/share/pki/nssdb \
-A -t "C,," -n "Example Corp Root" \
-i /usr/local/share/ca-certificates/corp.crt
certutil -d sql:$HOME/.local/share/pki/nssdb -LAnd then the part that makes this a Linux problem rather than a Chrome one. Firefox on the same machine is a third answer. It does not read /etc/ssl/certs and it does not read Chrome’s database. On Fedora it will work anyway, because Fedora points libnssckbi.so at p11-kit-trust.so and Firefox picks up the system store through the back door. On Debian it will not, because Debian does not.
So: one machine, one certificate, one correctly-executed installation command, and three programs with three different answers to “do I trust this”, only one of which is affected by the file everyone edits. None of them is wrong. When two tools disagree about a certificate, the disagreement is the measurement: they run the same handshake against the same bytes and differ only in which roots they trust, so which two agree names the store that is misconfigured, with nothing left to guess.
Related reading
- DNS, All the Way Down — the eight stages this article delegates, from
getaddrinfoto a cached answer - The Life of a Packet — what happens to the connection below the socket, on the way out and back
- What a TLS Certificate Actually Proves — the introduction that stage 5 goes underneath
- Containers, All the Way Down — the namespaces and seccomp filters the browser sandbox is built from

