DEV Community

Pasindu Balasooriya
Pasindu Balasooriya

Posted on Originally published at Medium on

Inside WSO2-ThunderID’s Post-Quantum JWKS

Everyone worried about the wrong thing.

When people talk about migrating to post-quantum cryptography, the first question is almost always performance. Will it slow down my auth server? Will verification cost me latency on every request?

I spent a while measuring this inside ThunderID, an open source IAM stack that ships ML-DSA signing today and the performance answer turns out to be boring. Signing a token with ML-DSA is faster than RSA-2048. Verification is in the same ballpark as ECDSA.

The problem is size. And size breaks things that speed never would.

First, here is one

This is a real JWKS document, produced by ThunderID’s JWKS service with an ML-DSA-87 signing key loaded. I have trimmed the public key in the middle because the full thing is 3,456 characters of base64 and would eat half this article.

{
  "keys": [
    {
      "kid": "q_Gzy1Asrf8PhpN7ZFg2pR8_HADURDcvfEZOwqDHYXI",
      "kty": "AKP",
      "use": "sig",
      "alg": "ML-DSA-87",
      "pub": "Cxfw7KPEFQQyjHaDETwOayeFNC09h1tYRLxwkyvt4q_xV0WoqsH3OK...
              ...5GfdFtksLtA49sTwfiDp0iAZCplRqumBlc72"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Look at kty. Not RSA, not EC, not OKP. AKP , for Algorithm Key Pair is a JWK key type introduced for exactly this. If your client library has a switch statement over key types and it almost certainly does, that switch does not have a branch for this yet.

And the token it signs:

header : {"alg":"ML-DSA-87","kid":"mldsa87","typ":"JWT"}
payload : {"aud":"my-app","exp":1790000000,"iss":"https://localhost:8090",
           "scope":"openid profile email","sub":"8f3a-user"}
length : 6386 bytes (signature alone: 4627 bytes)
Enter fullscreen mode Exit fullscreen mode

Six and a half kilobytes for a token carrying five claims. The claims are 118 bytes of that. Everything else is signature.

The numbers

Here is what each algorithm costs. I generated these through ThunderID’s own signing path rather than copying them from a spec, so they include the base64url encoding you actually pay for.

Going from ES256 to ML-DSA-87 takes a 302 byte token to roughly 6.4KB. That is about 21 times bigger. Against RS256 it is still more than 11 times.

Timing, for contrast, was a non-event. RS256 signing sat around 630µs across runs. ML-DSA signing ranged from about 95µs to 520µs. Verification for everything landed between single digit and 60µs, close enough to noise at this resolution that I would not read anything into the ordering.

That ML-DSA signing range is wide on purpose. ML-DSA uses rejection sampling, so it loops until it gets a signature meeting its constraints. Sometimes that is one pass, sometimes several. Benchmark it once and write down the number and you are quoting a coin flip. The sizes, by contrast, are fixed by the parameter set and did not move at all between runs.

The CPU cost of post-quantum signatures has quietly stopped being the interesting problem. The bytes have not.

Where 6KB tokens actually hurt

A 6KB token is fine sitting in a database. It stops being fine the moment it has to travel somewhere with a limit.

Cookies cap at 4KB. This is the one that bites first. RFC 6265 only requires browsers to support 4096 bytes per cookie and that is what they do in practice. An ML-DSA-87 token does not fit in a cookie at all. Neither does ML-DSA-65 once you add a realistic claim set. If your session design puts a signed token in a cookie, that design does not survive the migration without splitting the token or moving to a session reference.

Proxies cap request headers. nginx defaults large_client_header_buffers to 8KB and plenty of API gateways sit in the same range. One 6KB bearer token in an Authorization header usually squeaks through. That token plus normal headers or two tokens on one request will not. These failures are miserable to debug because they surface as a generic 400 from an intermediary that never tells you which header was too big.

URLs cap hardest. Anything putting a token in a query string or fragment is finished. The old advice about keeping URLs under 2,000 characters was already tight. A single post-quantum signature blows past it on its own.

None of this is an argument against post-quantum. It is an argument for finding out now which parts of your architecture quietly assume a token fits somewhere because those assumptions stay invisible until they break.

The part where the standard library lets you down

Here is the detail I did not expect.

Go’s standard library has no ML-DSA support. Not in crypto, not in crypto/x509. So if you want to load an ML-DSA private key from a PKCS#8 file, which is how anyone normally ships a signing key, you parse the ASN.1 yourself.

ThunderID has a file doing exactly that, and it is refreshingly honest about it:

// ML-DSA PKCS#8/ASN.1 key encoding helpers (RFC 9881). These fill the gap left
// by the Go standard library's lack of ML-DSA support. When crypto/x509 gains
// ML-DSA support (Go 1.27), delete this file and replace callers with
// x509.ParsePKCS8PrivateKey / x509.MarshalPKCS8PrivateKey.
Enter fullscreen mode Exit fullscreen mode

A whole file of hand-written ASN.1 with a note saying delete me when the language catches up. That is what post-quantum ready looks like in practice right now, and it is a long way from flipping a config value.

It goes further. RFC 9881 lets an ML-DSA private key be encoded three different ways: as a seed, as an expanded key or as a SEQUENCE holding both. All three are valid, so the parser accepts all three:

// ParseMLDSAPKCS8 parses a DER-encoded RFC 9881 PKCS#8 ML-DSA private key. It
// accepts all three private-key CHOICE encodings (seed [0], expandedKey, and
// the "both" SEQUENCE), preferring the seed when present.
Enter fullscreen mode Exit fullscreen mode

If you are planning your own migration, that is the interop landmine worth knowing about. Whichever tool generates your key picks an encoding and whatever consumes it has to handle the one you got. “It’s a standard PKCS#8 file” is doing a lot of work in that sentence.

The certificate side is stranger still. Go cannot parse an ML-DSA public key out of an X.509 certificate either, so the loader keeps the certificate DER as an opaque blob and derives the public key from the private key instead:

// ML-DSA: the standard library cannot parse the certificate's public
// key, so derive it from the configured private key.
Enter fullscreen mode Exit fullscreen mode

Everything works. It just works around the language rather than with it and it will keep doing so until Go 1.27.

Crypto-agility is not a config flag

The other thing that becomes obvious in a real implementation is how many places the algorithm choice reaches.

It is not just the signing call. It is the JWKS endpoint, which has to publish the right alg and kid so relying parties can verify. It is key management and storage. It is the discovery document advertising what the server supports. It is per-client negotiation, because in any realistic migration different clients are ready at different times and a client that cannot verify ML-DSA needs to keep getting ES256 without anyone running a flag day.

ThunderID’s config models this as a list of keys with an explicit choice of which one signs:

crypto:
  keys:
    - id: "default-key"
      cert_file: "config/certs/signing.cert"
      key_file: "config/certs/signing.key"
    - id: "mldsa87"
      cert_file: "config/certs/mldsa87.cert"
      key_file: "config/certs/mldsa87.key"

jwt:
  preferred_key_id: "default-key"
Enter fullscreen mode Exit fullscreen mode

Multiple keys registered at once, one selected as preferred. That shape matters more than it looks. A migration is not a switch from old to new, it is a stretch of time where both are live, both are published in the JWKS and traffic moves over gradually. A design holding only one signing key at a time cannot express that.

Trying it yourself

You need OpenSSL 3.5 or later to generate an ML-DSA key, which is the main friction right now. Most distributions are still on 3.0 to 3.4 and ML-DSA landed in 3.5.

openssl genpkey -algorithm ML-DSA-87 -out mldsa87.key
openssl req -new -x509 -key mldsa87.key -out mldsa87.cert -days 365 \
  -subj "/CN=thunderid-signing"
Enter fullscreen mode Exit fullscreen mode

Point a key entry at those two files, restart and the JWKS above is what you get.

If your OpenSSL is older, the ThunderID repo carries generated ML-DSA fixtures for all three parameter sets under backend/internal/system/kmprovider/defaultkm/pki/testdata/, which is what I used for the output in this article.

Should you actually do this

Not yet, for most people. The reason to care now is that the threat model was never about today.

Harvest-now-decrypt-later means someone records your encrypted traffic today and decrypts it when the hardware arrives. The signature equivalent is trust-now-forge-later, an assertion you sign today with a long validity becomes forgeable the moment the underlying assumption falls. If you issue credentials meant to be trusted for years the clock on those started when you issued them, not when quantum computers show up.

So the useful question is not “should I switch today” but “how long are my signatures meant to be trusted and do I know what breaks when I switch”. The second half you can answer this afternoon. Take your longest realistic token, add 6KB and go look at every place it travels.

The measuring is cheap. The architectural assumptions you find are the expensive part and they do not get cheaper by waiting.

Top comments (0)