Sign a JWT With ML-DSA in Delphi

· Components
Sign a JWT With ML-DSA in Delphi

A JWT is a small thing with a long reach. It is the proof of identity between your services, and the algorithm that signs it tends to be the slowest part of a system to change, because every issuer and every verifier has to move at once. That makes it worth knowing that the option exists before you need it.

sgcWebSockets 2026.10 adds the post-quantum JWT algorithms of RFC 9964 to the JWT client and server: ML-DSA-44, ML-DSA-65 and ML-DSA-87. They sit beside HS, RS and ES as three more values of the same property, and everything else about your token stays where it was.

Issuing a Token

Pick the algorithm in the header, give the component a private key, and sign:

oJWT := TsgcHTTP_JWT_Client.Create(nil);
oJWT.JWTOptions.Header.alg := jwtMLDSA65;
oJWT.JWTOptions.Algorithms.MLDSA.PrivateKey.Text := vPrivatePEM;
oJWT.JWTOptions.Payload.iss := 'my-service';
oJWT.JWTOptions.Payload.sub := 'user-1';

vToken := oJWT.Sign;

The private key is an ordinary PKCS#8 PEM. All three forms RFC 9881 defines are read, so a key stored as the 32 byte seed, as the expanded key, or as both, all work.

Validating One

The server side is the same shape, with the public key and the family switched on:

oServer := TsgcHTTP_JWT_Server.Create(nil);
oServer.JWTOptions.Algorithms.MLDSA.Enabled := True;
oServer.JWTOptions.Algorithms.MLDSA.PublicKey.Text := vPublicPEM;

if oServer.Validate(vToken, vHeader, vPayload, vError) then
  ShowMessage(vPayload);

Each family is gated on its own, so a deployment that only ever issues ML-DSA tokens can turn the others off and a token arriving with alg set to something else is refused rather than checked.

The Parameter Set Has to Match

There is one rule in RFC 9964 worth stating out loud, because it is the mistake that would otherwise go unnoticed: the key has to belong to the parameter set the alg names. A key that does not match is refused. Signing with the wrong one raises, and validating with the wrong one returns false rather than raising, so an attacker cannot use the difference to learn anything.

Keys as JSON Web Keys

RFC 9964 registers a key type for these algorithms, AKP, and the library reads and writes it, which is what you want when keys are published at a JWKS endpoint rather than dropped in a file:

uses
  sgcHTTP_JWT_MLDSA;

var
  vJWK, vPublicPEM, vPrivatePEM: string;
begin
  // {"kty":"AKP","alg":"ML-DSA-65","pub":"...","priv":"..."}
  vJWK := sgcMLDSA_ExportPrivateJWK(mldsa65, oSeed);

  // and back again, as the PEM the components read
  sgcMLDSA_ImportJWKAsPEM(vJWK, vPublicPEM, vPrivatePEM);
end;

The private member is the 32 byte seed, which is what the RFC defines, and the reader is strict about it: the members must be canonical base64url of exactly the right length, alg is required, and when a private key is present it must agree with the public one in the same JWK. A JWK that fails any of those is rejected instead of half loaded.

Interoperability, Demonstrated

A signature format is only useful if somebody else produces the same bytes. Two checks ship as tests rather than as claims:

While We Were In There: a Binary HMAC Secret

An unrelated limitation went away in the same pass. The HS algorithms took their key as text, and a real HMAC key is random bytes, most of which are not valid text at all. There is now a second property that takes the key as base64url, the same encoding the k member of an oct JWK uses:

oJWT.JWTOptions.Algorithms.HS.SecretBase64URL := 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ...';

When it is set it takes precedence over the text secret, on the client and on the server alike.

Claims Written by Somebody Else

One more fix worth knowing if you accept tokens from other issuers. A JSON string can be written as raw UTF-8 or with every non-ASCII character escaped, and different libraries choose differently. Both forms now resolve to the same text, so a name with an accent in it reads correctly whichever issuer produced it, and an alg header written with escapes is recognised as the algorithm it spells.

Upgrading

The three algorithms are additions at the end of the existing list, so a stored ordinal keeps its meaning and nothing you already do changes. Signing with ML-DSA needs the sgcCrypto pack, and a build without it refuses those tokens with a clear message instead of failing obscurely.

Watch It

There is a short video of this on the eSeGeCe channel.

Questions, feedback or migration help? Get in touch — you will get a reply from the people who wrote the code.