Half of this site's TLS handshake is already post-quantum and I did nothing to make that happen. The other half, authentication, moves on a completely different schedule, because signatures live in certificates and registries that a personal site does not control. So I went looking for the signature I do control, and measured what it would cost to migrate it.
Ask the live site what it negotiated:
$ openssl s_client -connect aadhar.sh:443 -groups X25519MLKEM768 -tls1_3
Negotiated TLS1.3 group: X25519MLKEM768
Peer signature type: ecdsa_secp256r1_sha256
Certificate: ecdsa-with-SHA256, P-256The key agreement is post-quantum. The certificate and the handshake signature are classical, and they will stay that way for a while: no public CA issues ML-DSA certificates into the WebPKI. Cloudflare's own position is that ML-DSA chains add roughly 14KB per handshake, which is why they are pushing Merkle Tree Certificates instead.
That split matters more than it looks. Key agreement is where harvest now, decrypt later bites: an attacker recording today's ciphertext can decrypt it whenever a quantum computer arrives, so the migration had to happen years early. A signature has no such property. Forging a 2026 signature in 2035 proves nothing about 2026, because by then the request is long over and nobody is still trusting it. The urgency argument for key agreement simply does not transfer.
| Surface | Algorithm | Whose problem |
|---|---|---|
| TLS to visitors | ECDSA P-256 | The CA and browser ecosystem |
| DNSSEC on the zone | ECDSA P-256 SHA-256 | The registry, plus DNS response sizes |
| Booking links, cover URLs | HMAC-SHA256 | Already fine. Symmetric, so Grover halves it to 128-bit |
| AadharshBot request signing | Ed25519 | Mine. About 30 lines in one file |
AadharshBot signs every outbound request per RFC 9421 using the Web Bot Auth profile, so receiving sites can check that a request really came from here. That signature is the only asymmetric one this site owns end to end. The whole post-quantum question collapses to it.
When I first measured this in July, Cloudflare's runtime had no post-quantum algorithm in WebCrypto. I checked the shipping binary rather than the docs: workerd 1.20260722 contained the strings Ed25519, X25519, and ECDSA, and no ML-DSA at any parameter size. So the blue bars are pure JavaScript, via @noble/post-quantum, signing the site's real signature base on an M3 Max under Node 26. The amber bar arrived in October, and the last section explains it.
Flip the toggle before you read on and predict where each bar lands. The point of the exercise is that the ordering changes. SLH-DSA-128s has the smallest public key of anything here at 32 bytes, and it takes 2.65 seconds to produce one signature. Its sibling 128f signs in 132ms and spends 22KB of header doing it. No row is small on both axes, and that is the state of post-quantum signatures, whichever library computes them.
Less than it looks, and the panel above will prove it on your own hardware if it can. Chrome ships ML-DSA in WebCrypto behind chrome://flags/#webcrypto-pqc, implementing the same WICG proposal whose JWK shape this site already publishes. If that flag is on, the panel offers a button that generates an ML-DSA-44 key in your browser, signs this page's signature base 200 times natively, and drops the result in beside the JavaScript number. The amber workerd bar answers the same question for Cloudflare's runtime.
This matters for how you read the whole table. The 8.58ms is a ceiling set by running a lattice scheme in a JIT, and the byte counts underneath it are properties of FIPS 204 that no implementation will improve. Sizes are the permanent cost. Speed is the temporary one, and it goes away on its own when the runtimes catch up. I wanted SLH-DSA to work. It rests on hash functions alone, so it survives even if lattices turn out to be broken, and its 32-byte public key is charming. It fails here twice over. The header is the harder failure. A 128f signature is 22,784 bytes once base64'd into a structured-fields value, and most servers cap total request headers around 8KB, so the crawl would start collecting 431s instead of pages. The latency is the other one: 2.65 seconds of single-threaded work per signature is not something to put in front of an HTTP request, and a Worker bills CPU time. Stateful hash-based signatures (LMS, XMSS) fix the size problem and introduce a worse one. Reusing a one-time index is total key compromise, and a Worker isolate is close to the worst place to keep a monotonic counter. I would need a Durable Object doing nothing but handing out indices, and a single replay would end the key. ML-DSA-44 is the only candidate that survives contact with the constraints. Here is the part that made this cheap. The IANA HTTP Signature Algorithms registry has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks. But A verifier that only knows Ed25519 reads The public key is published under the same JWKS as an About 8.6ms of CPU and 3,228 bytes of request header per signed fetch, plus 11.96KB gzip of lattice code in the Worker bundle, which is the price of carrying an implementation the runtime should be providing. Zero bytes reach a browser, because this is a server-to-server signature and no page loads it. The /around crawl signs roughly 21 origins per refresh, so a full cycle gains around 180ms of CPU on a cached background path. Two of those three costs are temporary. When One implementation detail worth stealing: ML-DSA-44 keygen expands a 32-byte seed into a 2,560-byte secret key and costs about 1.4ms. Worker isolates outlive a request, so the expanded key is derived once and cached in module scope, keyed by the raw secret so a rotation inside a live isolate re-derives instead of signing with a retired key. The strongest reading of this page is that it is theatre. Nothing verifies I mostly agree, and it is why What would change my mind: a receiving server rejecting requests because the extra 3.2KB pushes headers past its limit, or a verifier that chokes on a dictionary label it does not know. Either result would mean a second label is not free after all, and Neither of those things went wrong. No server rejected the headers, and no verifier choked on the extra label. The prediction above was accurate, but it was aimed in the wrong direction, because it priced the cost to other people and treated the cost to this site as a settled line item: 8.58ms, listed once, in a table. The number that mattered was 8.58ms multiplied by fan-out, against a per-invocation budget. This site runs on the Workers free plan, which allows 10ms of CPU per request. So one signature spent most of a request's entire budget, and every surface that signs more than once had already blown it: Both of those were dark and neither said so. The playlist scrape catches a failed track embed per track and degrades that one track to no cover, so 21 signatures timing out rendered as a tracklist that had quietly stopped showing album art. On Removing The honest reading is that the objection above was right for a reason it did not name. Shipping the migration early was cheap to build and the build was never the risk. What it cost was a recurring tax on every outbound request, on the one axis a free-tier Worker has least of, and the page that measured the tax in isolation is the same page that missed it in aggregate. A per-request cost is only a number once you know how many requests, and how much budget. None of this changes the sizing argument, which is the part worth keeping. The 3,228 bytes are still permanent, the speed is still temporary, and when That last paragraph made a prediction, and on 2026-10-01 I got to check it. Cloudflare has put ML-KEM and ML-DSA into WebCrypto on Workers. They run on BoringSSL, and each Worker opts in with the Signing got 72 times faster, and the header didn't lose a single byte. That's the asymmetry this page bet on in July. Natively, one ML-DSA-44 signature costs about as much as 10 Ed25519 signatures (11.3µs each on the same rig), where the JavaScript version cost as much as 170. Here is the table that retired On the laptop, one signature takes about 1% of a 10ms request budget, and the worst fan-out on the site would sign everything it needs in about a third of a single budget. Hold that projection loosely; production priced it at 3 times that. A 72x speedup deserves a control, so I ran three. Without the flag, the same runtime refuses outright: A Worker's clock only advances across I/O, so a Worker can't time its own crypto. The timings are wall-clock from outside instead: 2,000 signatures inside one request, minus an empty request so the dispatch cost cancels, median of 9 runs. The browser button above times yours the same way. The objection I think is right survives intact. Nothing on the internet verifies The retirement section also promised Those were laptop numbers, and production bills CPU on different hardware, under a clamp that tightens with load. That is exactly where this page went wrong the first time. So before Signing everything cost 10ms a scan, about 0.33ms a signature, which is 3 times the laptop figure. August's lesson applied a second time: the per-signature price was right, but the price that mattered was that number on production's CPU, multiplied by fan-out. So The flag carries its own risk, since Cloudflare gates it "while the specification is still moving." If the API breaks, this site loses the second label and nothing else: any ML-DSA failure drops In July this page priced three costs: CPU, bundle size, and header bytes. Two belonged to the runtime, and the runtime has now paid them. The 3,228 bytes belong to FIPS 204, so they're the only number left to decide on. That's the rule I'd hand anyone timing a post-quantum migration: decide on the sizes, because the speeds come down on someone else's schedule.Why the hash-based options lost
Shipping it without a codepoint
Signature-Input and Signature are structured-fields Dictionaries. A second label appends rather than replaces, so both signatures ride the same request over the same covered components:Signature-Input: sig1=("@authority" "signature-agent");created=1785186311;
keyid="091z_BcIsuIgfzzKCKOie6JcSkz7UhVlbiakxjV5E8o";alg="ed25519";tag="web-bot-auth",
sig2=("@authority" "signature-agent");created=1785186311;
keyid="rn-mldsa-2026-07-27";alg="ml-dsa-44";tag="web-bot-auth"sig1 and skips the label it does not recognise. Nothing breaks, and the cost of ignoring sig2 is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token ml-dsa-44 is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and /bot says plainly that you should verify sig1 and ignore sig2.AKP key per RFC 9964, which standardised ML-DSA for JOSE and COSE in May 2026. So the key format is a real standard even though the wire token is not:What it costs in production
workerd gains native ML-DSA, the bundle cost goes to zero and the signing cost drops to whatever the native bar in the panel above showed you. The 3,228 bytes of header stay forever. That asymmetry is the argument for migrating on the size budget now and letting the speed sort itself out.The objection I think is right
sig2. Signatures have no harvest-now-decrypt-later exposure, so there is no clock forcing the migration. By that argument the honest move was to write the analysis and ship nothing.sig2 is second rather than first, why a missing key degrades to Ed25519 alone instead of failing the request, and why /bot tells verifiers to ignore it. What I get in exchange is a migration already paid for and measured, on a surface where being wrong costs a crawl rather than a login. When the registry does land a codepoint, the remaining work is renaming a string.sig2 would come straight back out. The crawl logs are where that would show up first.What actually happened: retired after 19 days
Surface Signatures per request ML-DSA CPU A single crawl of one origin 1 ~8.5ms of a 10ms budget The /rn playlist scrape 21, one per track ~180ms /lens discovery 28 probes ~240ms /lens it was louder but no better attributed: sampling production logs on 2026-08-15, 31 of 51 requests died exceededCpu, nearly all of them /lens/fetch and /lens/tools.sig2 took the scrape from 379ms of CPU to 95ms, measured on the same 21-track playlist. The key is out of the JWKS too, because publishing a key nothing signs with is the dangling-pointer problem this site refuses elsewhere.workerd ships native ML-DSA the CPU objection evaporates and sig2 can come back with no code to write beyond deleting this section. Until then the migration is measured, documented, and turned off.Then the runtime caught up
webcrypto_modern_algorithms compatibility flag (the announcement). This site already pins a workerd build from 2026-09-26 that carries both, so I measured it on the same M3 Max that drew the JavaScript bars.Same laptop, same signature base JavaScript workerd, native Sign once with ML-DSA-44 8.58ms 0.12ms Expand the 32-byte seed into a signing key ~1.4ms 0.03ms Signature in the header 3,228 bytes 3,228 bytes sig2, repriced:Surface Signatures per request JavaScript Native, on the laptop A single crawl of one origin 1 ~8.5ms ~0.12ms The /rn playlist scrape 21 ~180ms ~2.5ms /lens discovery 28 ~240ms ~3.4ms How I checked it
Unrecognized key generation algorithm "ML-DSA-44" requested. With it, a key shaped like the one still sitting in this site's secrets (a bare 32-byte seed in an AKP JWK, with no public half) imports and signs. And Node 26's WebCrypto, which runs on OpenSSL instead of BoringSSL, verifies workerd's signature and rejects it the moment one byte of the signed base changes.What it leaves open
sig2, the IANA registry still lists six classical algorithms, and every signed request would still carry 3.2KB more header for a signature with no harvest-now clock behind it.sig2 could return "with no code to write." That was wrong. The library left with the label, so bringing it back means a compatibility flag on the whole Worker plus a signing path through crypto.subtle. It's a small change, and a real one.What production said
sig2 came back, I deployed it at 0% beside the live version and pinned six /lens scans to it, each against a fresh origin so no cache could skip the signing.Version /lens scan CPU, median of 6 Live, sig1 only 22ms sig2 on every request 32ms sig2 on primary fetches only 22ms (live read 20ms in the same run) sig2 now rides on the requests that are this crawler's identity (the page being scanned, /around, the playlist) and skips the 28 discovery probes and the robots.txt reads that only look around. That costs about 2ms a scan, inside the noise between two runs of the live version.sig2, logs once, and sig1 ships alone.