x5c is not so bad

Stick certificates in your JWTs. It's fine.

An anthropomorphized illustration of a 1950s computer inspecting a paper tape. Boris Artzybasheff, cover for Description of a Magnetic Drum Calculator, Harvard University Press, 1952.
Boris Artzybasheff, dust jacket illustration for Description of a Magnetic Drum Calculator, Harvard University Press, 1952. Source

I would like to start this post by noting I am generally a fan of the RFC 7519 JSON Web Token (JWT) family of standards. As web standards go, JWT solves real problems by providing a means to ensure integrity when sending claims from one party to another. When paired with public-key cryptography and associated infrastructure, JWT makes it fairly easy to compose Web technologies and build robust mechanisms for transmitting claims safely. Rather than write about the merits (and problems) of JWT, however, I would like to focus on its use in producing signed tokens backed by public keys.

Typically, encryption with public-key cryptography involves one party encrypting a message for an intended recipient using a public portion of a key and a different party decrypting that message using a private portion. Digital signature schemes like those in RFC 7515, or JSON Web Signature (JWS), invert this: one party signs a message using their private key, and many parties can validate it using a public key. Assuming the signing party has not lost control of the private key, this allows other parties to confidently assert a given set of claims could only have come from the signing party. For digital signatures to work, then, the validating parties must possess a public key that is valid for the given message.

It is not enough to say a given signing key is valid, however; you must also know it is a key you can trust. Building this trust in JWT is somewhat complex. JWS provides no fewer than seven header values for discovering the key used to sign a message. Each of these values describes a different flavor of key discovery. kid, for example, corresponds to the ID of a key that a program might find somewhere else. jwk embeds the key itself in the token, making token validation trivially easy but largely worthless unless the validating party already trusts that specific key.

Addressing this requires some means of distributing keys that is itself trustworthy. By far the most popular class of methods for doing so involves key discovery using well-known HTTPS URLs. OIDC Discovery, for example, requires that the iss claim is an HTTPS URL and that this URL plus the path /.well-known/openid-configuration points to another URL; this final URL then contains signing keys in RFC 7517 JSON Web Key (JWK) format. This scheme has the unfortunate distinction of requiring that a client inspect a token's payload before validation to find its signing keys, but not all such schemes have this requirement. In many cases, just knowing the URL to a JSON Web Key Set (JWKS) will suffice. Regardless of how one gets the URL, though, schemes like these effectively bind the JWT to a separate chain of trust by requiring the use of HTTPS and TLS for key discovery.

The x5c header in JWT provides a different path for token validation. Rather than rely on a second root of trust for key distribution, or introspection of untrusted token claims, tokens leveraging the x5c header can embed a full chain of trust in the token itself. For example, consider the following token (line breaks for readability delimited by \):

eyJ4NWMiOlsiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVWTNla05EUVRs\
bFowRjNTVUpCWjBsVlNFeG5hMUZJVlc5clJHaHpSU3N6VW5SMGRtdEphelJ4ZUN0dmQwUlJXVXBMYjFw\
SmFIWmpUa0ZSUlV3S1FsRkJkMWRxUlV4TlFXdEhRVEZWUlVKb1RVTldWazE0UmxSQlZFSm5UbFpDUVdk\
TlJFWkNiR0p0TlhwbFYzZ3lXVmMxY0ZsVVJWWk5RazFIUVRGVlJRcENkM2ROVlVkb2NHSkhSbXRhVjNo\
M1lVZHNhRTFTTUhkSGQxbEVWbEZSUzBSQ1VrWmxTRUpzWTIxc2RGcFhOVEJaVjNkblZUTnNlbVJIVm5S\
amVrRmxDa1ozTUhsT1ZFRTFUVlJaZVUxcVJURk5WR2hoUm5jd2VVNVVRVFZOVkdONVRXcEZNVTFVYUdG\
TlNVZENUVk5WZDBsM1dVUldVVkZFUkVKNGVtRlhaSFVLV2xoSmVFeHRWalJqUjFaNVlWY3hiR0p1VW1o\
aVF6VjZaVmhPTUZwWE1YcE5VWE4zUTFGWlJGWlJVVWRGZDBwV1ZYcEZWazFDVFVkQk1WVkZRMEYzVFFw\
VlIxWjFZbTVPTldKSVdtaGliV3hvVFZKVmQwVjNXVVJXVVZGSVJFRjRVV0ZIYkhOWlYxSnNZa2hDYjJG\
WFJYaElWRUZpUW1kT1ZrSkJiMDFHUlZZMENtTkhWbmxoVnpGc1ltNVNhR0pEUWxSbFdFNHdXbGN4ZWsx\
SlNVTkpha0ZPUW1kcmNXaHJhVWM1ZHpCQ1FWRkZSa0ZCVDBOQlp6aEJUVWxKUTBOblMwTUtRV2RGUVhJ\
dlJXZGFWMDVGTm0wdlJWRmFObEJYZHpaeWJIVlFPSEJ3Tmxnd1VHRjRVMHhUUlVOcVprODRaV3N3U1V0\
V1FrSTVOblJ0U0dSRU1tNXpNQXB4TUhCMFpFTk1lVVEyWmxRNE4xb3dRa0ZMTldSMVozWm1kemh2YVho\
MmJtdEpMMHhMTHpobmFqbHFXRWhoTml0SmIwSmxORXhrTmt3eFRtZGhNVVIxQ2tOV1RVOXFOVzFwYW5K\
Q1FWUjRiMWxoTUVkclZFUXplblF5U3pOQlNsVTVlbWswV0hRMlZXVkdSV1ZKWTBoNGNFOW5VRUpzTWtO\
RVVreEhlWFZwTW1VS1JqbHlRa2xEWTJ0aVZ5dFdkR0ZuWVd0UFQwdHNlV0ZNUzNGalltVjRka3R3WmtK\
Sk0ydDRWWEZyYmpaMGRIRTFPQzlMVGs1Vk9FdFRkV2xuVG5kUldBb3dPVzgzWnpkNVNrZ3haRVZpTUhn\
elEwRktlR3hpUzNodVV6UjRNRW95S3pjeVZ6QktkekZWZUZaT1dTOUtPVnBzUjFKSGVFbEZlbUZ3ZFV0\
UVowVk5DbmR5Ymt3NVZWUjFiWGxvYXk4ME5IbE9kUzlyZGpCUFpESnlOamhTTm5BM1MzQm9TR3QxVFdS\
SlJWZ3pLMUpuWkZkNlNtdDVjWEJTTkRWbVYyaGhXRWtLZDBwclJWbFFVRGs0Y0dwdFNWSlpUM2RXUWxa\
cmVuZ3pTVGRPZEUxNU4xTndRak5WZERobFdFSjFWRzUyY0ZJNFV6ZE1TRVpHVDBobk16Rm9lVGRXWWdw\
MGQzbDNWVkJhVFdkUE5IRXZibTFHZGxGME9HNXJTelkyYTJNNGNHWlVUbFpXVUdreFZuSmtMM05qZG1o\
dGJHSTBTblpuVDNweVFtVkhNMkZIWVVObkNtTlVkVGN2VHpsb09WVlZSek52TXpCNFJIUTBiMkpPUkRN\
dlluRlJNRVZUTVZRNWNUSkdRVkZ2Y0RWcldHNXlTVlJPWXpKakt6QnhTekZZTDJodFdGSUtka0pyUkN0\
RVJ6ZExZeTkxYmtkcVdVZDZSazVsWW14WWVFRlliMWQ2WWpSMWEzQktPR3MzT1RoV1ExSkNkRkJZZUVs\
blRrNHliVVUxVWxObFdXSlVWQXBXTDNwTVVWVnRNa2RFZVRaTVpEZzNNVmxIY1hGTlpHMU9LM0ZtZG14\
WU0yTnFORlZXY3pobk5VdG5aa2hMVFVOQmQwVkJRV0ZQUW1oRVEwSm5WRUZtQ2tKblRsWklVMDFGUjBS\
QlYyZENWR1V6Tm1SWFVTdHdkeXQzVm1FeVZpOVdaRGx0WTJoMVlqZGxWRUZLUW1kT1ZraFNUVVZCYWtG\
QlRVRnpSMEV4VldRS1JIZFJSVUYzU1VoblJFRnVRbWRPVmtoU1JVVkpSRUZsWjJoNGVtRlhaSFZhV0Vs\
NFRHMVdOR05IVm5saFZ6RnNZbTVTYUdKRE5YcGxXRTR3V2xjeGVncE5RakJIUVRGVlpFUm5VVmRDUWxK\
MmRHVkljRXhtVmtWaVkzZzJTREZZT0ZGcmJWQnNaVGhSYm1wQlRrSm5hM0ZvYTJsSE9YY3dRa0ZSYzBa\
QlFVOURDa0ZuUlVFd1ptOVFObFJsYVVjdmVXdE9jU3RFYkVZNGRrNWhiMjVCTTFWNmNHeFRWakU1TmpC\
eEszcG5SV1JxVG5wNk5USjZkakJ1U25kV2RsVm5WRGdLVUdwd1JuaFBLMFppTDNOMWFtWklhbk5zU0hk\
TFdtbzFZWEpvYm5WMlEyRTJlWGhuUmtST2QzQkdOMGhJZEdKbGVEZERXbUpJWVM5bE5rSTRUblJ2V1Fw\
VGJ6aENhRXhFTUVoRGRURnBheTlWVlhwR1QyUlZhMWhqUkN0TVJEaFRNRGhXTkdSNGJFWTNMMEp1Y0Uw\
M1ExVXpjMk5ZYjFadFFsbEJTelZoTkZSbENqWnRlVFJGTlRoS2JTdHVjbFZIWWtWaU4waGhkMnh0WTJK\
MmMwWnJaVnBCSzFkSVJqQnBWbUUxVTNnM2FGTkRNMDlUYkVWYVQxQjNSSHBKVTFsb2JtZ0tSVk16ZWto\
dmRuUXhRVmxNTTNsT1dITlJlbkpXY1ZWMVIzcGFhbTVHTmpod2ExcE5jVFZTSzBoRFQybEphRWhrU0VW\
NmFFNU5kVmxST0VkaVpGUndlQXBOY1hSV1drRmtWbU5SYjA1R1VrTjRVMkZJWml0dE5GWkxNRGxqWjFn\
ck5WVldWakVyVUNzNVJHRm9kbmR1TlhOSmIzUkplRFJFTkVoNk1rNUxUa3hJQ2lzMVVHeENjRFUwV2pa\
NlEwdHFWbUppTDNOMUt5OHhUV3hQUkUwd05VOU5VRlYxTUdOVU5FVllVRnBZYm5WVFNYWnhNVVJZSzFW\
NVkzZHdaaXRHWWxFS1dIa3JLM0JUV0hCTU5XNWxTbEZ3ZGpZdlRIbzVRMnh6YkRWUVdpdEVjazFoVlZr\
eGVVZFBOV2RvTDJadlVHZGlUbE41UldkVVExUkpWMHhGWjJWVE5BbzJWa05yY0hsUWQxTm9WeXRVSzNO\
Qk1rRkdkV0ZvWmxabFJuWjViVk51UTJwRWQxZEpORFo1TmtJMWVHRktlREJZZURZeWVUVjVhbU5aVVZa\
MGNtTmFDa3RwT1dWSE56QkpRbVF4YUN0MFJtTkxiM05CVXpobGJXTTVTalYxWkdOc09WZG9PRmRDWm1o\
VGFGaExlbWhuZVVaTVdteGlLeXRtVGxSSWRrczBhWGtLUld4SmExVkpVR2M0WWxaNmRtMTJZV1pOZFU1\
eFVXMUVZbkJqT1RGcFlqUlpSMHgxZUdwMlpHNDFXWHBSVFUwOUNpMHRMUzB0UlU1RUlFTkZVbFJKUmts\
RFFWUkZMUzB0TFMwPSJdLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmb29AZXhwZXJ\
pbWVudGFsLnN5c3RlbXMiLCJleHAiOjE3NTgwNzQzOTAuNDAwOTMyNn0.cMyjBJLGwicwIEoPdxxPgrx\
ijHc2apJh8ju890eeoARbruw79ZmwrvtJBcSAECQt-IF93j5RLSKS5ICt_YgbRdMZmL4Zy0tY6VEcMP7\
a1p_JVCHqrchvWMLU0rzBGrQ1pCEWGJrp-DSq62d4Lfo_cnpf2ebRjglbeyXG3iRLb6hrErwiWQ91WYq\
faKzB-TAHyBdV09pqHgBbZj0ZBhJBJ9ZiXpb9PhoyLfsrJeJaww4WcFX--2xKtM5oF4HX_M0tB5Ywo7_\
M3dnsKyrJwkrVjhJ16VlKlIFsLUDEuWsHR-LmOR6ghvnWW8pFKTfSojPJ36TXdzRRfTYVeHGA2RhnpMr\
ro9HaVuQ-YOKDkDBi91d6sYjGBqeNgXh_xKFQZ_5rXrVzkipHqvhueUX7g-pefTKXPblC7fRJ6mhCWd-\
TrIqKZgGTwkC8xJNo2DXRtD9rsGnkWIFenn3IWT1G_nD_H_0MHPvlvc5xkkpSrqqVKNVYkRcUftsSqta\
2_keSr-xjruqihqtu9SHs9z81rgjWAkKjNgrrfT1xEzDTIaDIH2SIWh0u0AK-pAL78TRvWqnU9IdZJtm\
b-CQ0YqI_dsmQdR4WjwZA8P_pFIYLYl73a9tWKHRJOPacEeqEq0RXViP9cTbzfdHxr6Wn3-21qjI2mYY\
vU3SsF6CYSX6TB7q-xF0

If we unpack this token, we get the following header:

{"x5c":["LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUY3ekNDQTllZ0F3SUJBZ0lVSExna1F\
IVW9rRGhzRSszUnR0dmtJazRxeCtvd0RRWUpLb1pJaHZjTkFRRUwKQlFBd1dqRUxNQWtHQTFVRUJoTUN\
WVk14RlRBVEJnTlZCQWdNREZCbGJtNXplV3gyWVc1cFlURVZNQk1HQTFVRQpCd3dNVUdocGJHRmtaV3h\
3YUdsaE1SMHdHd1lEVlFRS0RCUkZlSEJsY21sdFpXNTBZV3dnVTNsemRHVnRjekFlCkZ3MHlOVEE1TVR\
ZeU1qRTFNVGhhRncweU5UQTVNVGN5TWpFMU1UaGFNSUdCTVNVd0l3WURWUVFEREJ4emFXZHUKWlhJeEx\
tVjRjR1Z5YVcxbGJuUmhiQzV6ZVhOMFpXMXpNUXN3Q1FZRFZRUUdFd0pWVXpFVk1CTUdBMVVFQ0F3TQp\
VR1Z1Ym5ONWJIWmhibWxoTVJVd0V3WURWUVFIREF4UWFHbHNZV1JsYkhCb2FXRXhIVEFiQmdOVkJBb01\
GRVY0CmNHVnlhVzFsYm5SaGJDQlRlWE4wWlcxek1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzh\
BTUlJQ0NnS0MKQWdFQXIvRWdaV05FNm0vRVFaNlBXdzZybHVQOHBwNlgwUGF4U0xTRUNqZk84ZWswSUt\
WQkI5NnRtSGREMm5zMApxMHB0ZENMeUQ2ZlQ4N1owQkFLNWR1Z3ZmdzhvaXh2bmtJL0xLLzhnajlqWEh\
hNitJb0JlNExkNkwxTmdhMUR1CkNWTU9qNW1panJCQVR4b1lhMEdrVEQzenQySzNBSlU5emk0WHQ2VWV\
GRWVJY0h4cE9nUEJsMkNEUkxHeXVpMmUKRjlyQklDY2tiVytWdGFnYWtPT0tseWFMS3FjYmV4dktwZkJ\
JM2t4VXFrbjZ0dHE1OC9LTk5VOEtTdWlnTndRWAowOW83Zzd5SkgxZEViMHgzQ0FKeGxiS3huUzR4MEo\
yKzcyVzBKdzFVeFZOWS9KOVpsR1JHeElFemFwdUtQZ0VNCndybkw5VVR1bXloay80NHlOdS9rdjBPZDJ\
yNjhSNnA3S3BoSGt1TWRJRVgzK1JnZFd6Smt5cXBSNDVmV2hhWEkKd0prRVlQUDk4cGptSVJZT3dWQlZ\
rengzSTdOdE15N1NwQjNVdDhlWEJ1VG52cFI4UzdMSEZGT0hnMzFoeTdWYgp0d3l3VVBaTWdPNHEvbm1\
GdlF0OG5rSzY2a2M4cGZUTlZWUGkxVnJkL3NjdmhtbGI0SnZnT3pyQmVHM2FHYUNnCmNUdTcvTzloOVV\
VRzNvMzB4RHQ0b2JORDMvYnFRMEVTMVQ5cTJGQVFvcDVrWG5ySVROYzJjKzBxSzFYL2htWFIKdkJrRCt\
ERzdLYy91bkdqWUd6Rk5lYmxYeEFYb1d6YjR1a3BKOGs3OThWQ1JCdFBYeElnTk4ybUU1UlNlWWJUVAp\
WL3pMUVVtMkdEeTZMZDg3MVlHcXFNZG1OK3FmdmxYM2NqNFVWczhnNUtnZkhLTUNBd0VBQWFPQmhEQ0J\
nVEFmCkJnTlZIU01FR0RBV2dCVGUzNmRXUStwdyt3VmEyVi9WZDltY2h1YjdlVEFKQmdOVkhSTUVBakF\
BTUFzR0ExVWQKRHdRRUF3SUhnREFuQmdOVkhSRUVJREFlZ2h4emFXZHVaWEl4TG1WNGNHVnlhVzFsYm5\
SaGJDNXplWE4wWlcxegpNQjBHQTFVZERnUVdCQlJ2dGVIcExmVkViY3g2SDFYOFFrbVBsZThRbmpBTkJ\
na3Foa2lHOXcwQkFRc0ZBQU9DCkFnRUEwZm9QNlRlaUcveWtOcStEbEY4dk5hb25BM1V6cGxTVjE5NjB\
xK3pnRWRqTnp6NTJ6djBuSndWdlVnVDgKUGpwRnhPK0ZiL3N1amZIanNsSHdLWmo1YXJobnV2Q2E2eXh\
nRkROd3BGN0hIdGJleDdDWmJIYS9lNkI4TnRvWQpTbzhCaExEMEhDdTFpay9VVXpGT2RVa1hjRCtMRDh\
TMDhWNGR4bEY3L0JucE03Q1Uzc2NYb1ZtQllBSzVhNFRlCjZteTRFNThKbStuclVHYkViN0hhd2xtY2J\
2c0ZrZVpBK1dIRjBpVmE1U3g3aFNDM09TbEVaT1B3RHpJU1lobmgKRVMzekhvdnQxQVlMM3lOWHNRenJ\
WcVV1R3paam5GNjhwa1pNcTVSK0hDT2lJaEhkSEV6aE5NdVlROEdiZFRweApNcXRWWkFkVmNRb05GUkN\
4U2FIZittNFZLMDljZ1grNVVWVjErUCs5RGFodnduNXNJb3RJeDRENEh6Mk5LTkxICis1UGxCcDU0WjZ\
6Q0tqVmJiL3N1Ky8xTWxPRE0wNU9NUFV1MGNUNEVYUFpYbnVTSXZxMURYK1V5Y3dwZitGYlEKWHkrK3B\
TWHBMNW5lSlFwdjYvTHo5Q2xzbDVQWitEck1hVVkxeUdPNWdoL2ZvUGdiTlN5RWdUQ1RJV0xFZ2VTNAo\
2VkNrcHlQd1NoVytUK3NBMkFGdWFoZlZlRnZ5bVNuQ2pEd1dJNDZ5NkI1eGFKeDBYeDYyeTV5amNZUVZ\
0cmNaCktpOWVHNzBJQmQxaCt0RmNLb3NBUzhlbWM5SjV1ZGNsOVdoOFdCZmhTaFhLemhneUZMWmxiKyt\
mTlRIdks0aXkKRWxJa1VJUGc4YlZ6dm12YWZNdU5xUW1EYnBjOTFpYjRZR0x1eGp2ZG41WXpRTU09Ci0\
tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="],"typ":"JWT","alg":"RS256"}

We also get the following payload:

{"sub":"foo@experimental.systems","exp":1758074390.4009326}

To validate this token, all we need is the root CA certificate for the certificate chain. From there, we can validate the whole chain, extract the key from the leaf certificate, and use that to validate the token. The token is larger, but in exchange clients have stronger guarantees regarding the trustworthiness of a key. An attacker cannot, for example, continue using a stolen signing key for longer than the lifetime of the certificate.

Tokens backed by X.509 certificates have several advantages over those backed by bare keys (e.g., those found using OIDC Discovery). For parties that consume JWTs, certificates provide defined key lifetimes and a chain of trust embedded in the x5c header itself. This allows said parties to rely only on a predefined X.509 trust root and X.509 certificate validation logic to ensure a signing key is valid. For parties that produce JWTs, certificate lifetimes provide an upper bound on how long a key can be used alongside information about the signer's identity, mitigating whole classes of security and reliability concerns when dealing with key material.

Consider, for example, a JWT signing service distributed across many regions that is subject to fluctuating load. An OIDC-like key discovery scheme requires that all service replicas have some shared knowledge of current key material and clients stay up to date on valid signing keys. Both of these properties mean the signing service is much more likely to rely on long-lived keys and complex, risky procedures for key rotation in the event of scheduled updates or security incidents. Conversely, a service that uses signing keys backed by X.509 certificates and x5c headers can leverage short-lived certificates from an upstream issuer for signing tokens. Such a scheme does not require that service replicas have any shared knowledge - each replica can request its own certificate - and JWT's existing reliance on reasonably accurate clocks means those certificates can have lifetimes as short as the tokens themselves. In the event that the service is scaled up, a given replica of the service restarts, or the service itself is compromised, fresh replicas can simply request more certificates and wait for old ones to age out.

Despite the advantages of an X.509-based signing scheme over a discovery-based signing scheme, JWT implementations that use X.509 and x5c are comparatively rare. I myself have only seen it used in one service. One might wonder why this is so.

One important factor here is that tokens with embedded certificates are large relative to their payloads. The token in the example above, signed with a 4096 bit RSA key, is 4580 bytes where only 80 of those represent the payload. This means under 2% of the token is actually meaningful information for the consumer; the rest is signature data. Tokens of this size are also larger than some web servers allow in HTTP headers, which means a token signed with a certificate may not be usable in all contexts.

Another is the popularity of OIDC itself and the sets of problems JWT and X.509 aim to solve. The OIDC specification explicitly states that ID tokens (which are always JWTs) should not include the x5c header and that all parties should use discovery mechanisms like OIDC Discovery for key distribution. Within the JWT family of standards, JWK already provides mechanisms for defining key types, parameters, and permissible uses. X.509 provides all of this and more, but it comes with its own complexities and is generally used in different domains from JWT (e.g., TLS rather than HTTP authentication). Given these issues, existing JWT implementations frequently do not support the use of x5c or similar headers like x5t in token signing and validation, making it difficult to add them to a token even if one wants to do so.

Still, just because something is not popular does not mean it is bad. Certificates have concrete benefits when one wants to be sure of the lifetime and provenance of a given key, and their use in JWT can greatly simplify key management, especially in tandem with technologies like SPIFFE and ACME for automating certificate issuance. Bootstrapping trust is still a concern, but even that is simpler than it would be when using key discovery. Writing the requisite code is not necessarily difficult either; while the entire JWT family of standards is complex, supporting a subset requires little more than Base64, JSON, and a decent cryptography library. If you find yourself working with a project that requires JWTs, consider adding x5c to your stack. You might even like it.