Fresh out of the Christmas holidays, Alice and Bob wanted to resume their work where they left off: integrating self-sovereign identities into their tooling. Just before the Christmas holidays, they have identified a new battleground: JWT verification, the process of determining which public key signed a JWT and using that public key to verify the signature.

Bob used to love JWTs for their simplicity, he knows a number of great libraries across multiple ecosystems that implement JWT verification. But Alice had given him quite a few “malicious” examples to chew on. Something about JWTs she recovered from several self-sovereign identity flows. His libraries could not make heads or tails of it, so here they were, starting the new year at the drawing board to understand JWT verification.

To avoid opening Pandora’s box, Bob set themselves a narrow scope for this exploration: “We’ll definitely ignore JWT validation for now, so we will assume that our JWTs are structurally valid, non-expired etc., and we will just study public key resolution through means other than certificate chains because our issues are not caused by PKI-based resolution.”

JWT headers: the beginning of the story

For a bit of terminology, Alice repeated what they already knew: A JWT is a string that contains three parts, each base64url-encoded and separated by a period:

  • the JWT header which determines the content type of the JWT’s payload and, most importantly for this discussion, identification of the key material;
  • the JWT payload which is the content that consumers are actually interested in, consisting of registered claims like the issuer or subject and custom claims that are application-specific; and
  • the signature which signs the message <base64url-encoded header>.<base64url-encoded payload> using the chosen signature algorithm.

As an example, the JWT eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4g
RG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.JkKWCY39IdWEQttmdqR7VdsvT-
_QxheW_eb0S5wr_j83ltux_JDUIXs7a3Dtn3xuqzuhetiuJrWIvy5TzimeCg has the following three components (the example by courtesy of jwt.io, a great online platform for looking into JWTs):

  • a header {"alg": "EdDSA", "typ": "JWT"} that specifies that this is an Ed25519-signed JWT; Ed25519 signature meaning that it’s asymmetric and we need the signer’s public key to verify the signature,
  • a payload to the extent of
{
	"sub": "1234567890",
	"name": "John Doe",
	"admin": true,
	"iat": 1516239022
}
  • a signature allowing us to verify this JWT by using the signer’s public key to check if hashing eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6I
    kpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0 for the signature matches JkKWCY39IdWEQttmdqR7VdsvT-_QxheW_eb0S5wr_j83ltux_JDUIXs7a3Dtn3xuqzuhetiuJrWIvy5TzimeCg.

Bob immediately caught on: “Wait a minute, you said it’s asymmetric and we need a public key for verification. But where do we get the public key to verify that signature? Neither the header nor the payload specify that.” Alice began to smirk, “Yes, that’s where the magic starts. You see, a JWT is not required to be self-contained, the key exchange may happen out of band.”

So Bob dove right into the standard to emerge with the realization that JSON Web Signatures are a lion’s den of interpretations for verification paths. That’s when he started to craft an example of what he would like to receive when verifying a JWT. He found two very obvious paths to key identification:

  • The JWT header allows to set a jwk. After diving into different DID methods in the past, Alice and Bob were quite familiar with JSON Web Keys (JWK). So your header would say {"alg": "EdDSA", "typ": "JWT", "jwk": {…}} and you can simply use that to verify your JWT. Nice and easy, who wouldn’t wish to be done at this point, Bob thought.
  • Another obvious path to the public keys seemed to be the combination of the jku and the kid header. The first specifies a URL to a JSON Web Key Set (JWKS) which is basically a list of published public keys in JWK format. The second is the key identifier which identifies a key in the JWKS. So the process of getting the public key would be a GET <jku header value> and then a search for an element where the kid matches.

Seeing such trivial paths to public keys, Alice immediately wondered why it’s so hard for issuers of JWTs to go down this path. But well, reality trumps theory, and in reality signers often seem to omit these obvious fields, especially in the world of SSI.

When you start paying for a payload

Having finished their look at the ways they could identify the public key through the JWT header, they now started to address the much more interesting aspect: the JWT’s payload as a source of key material. For that, they took a look at the registered claims. Ignoring those that were intended for temporal validation, Bob summarized the following two registered claims as particularly relevant:

  • iss, the JWT’s issuer. He just hoped that the signatory of the JWT would usually also be related to the JWT’s issuer. Otherwise, his mental model of JWT issuance was close to exploding.
  • sub, the JWT’s subject. Which strictly speaking is something the JWT makes claims about, not who makes the claims, however there are enough use-cases for issuing claims about oneself in the SSI universe to make this a field of interest.
  • aud, the JWT’s audiences. Which again is something completely unrelated because it specifies whom the JWT addresses (who is supposed to be able to read, verify and process it). But well, maybe one of the intended audiences is nice enough to expose its own sources of verification, so let’s not exclude it prematurely.

JWT issuers: DIDs and other URLs

After enumerating all these options, Alice suggested to start with the easy path. In that case, looking at the issuer and how the issuer may publish the public keys it expects others to use to verify whatever JWTs it issued. In the SSI world, the obvious path for that would be to use a did (decentralized identifier) to communicate how to resolve the public key material.

In their previous SSI adventure about DID methods, Alice and Bob already learned about a few DID methods. Without repeating everything they learned there, the three main paths to the public keys are:

  • ephemeral DIDs like did:key or did:jwk which directly encode the public key,
  • hosted DIDs like did:web:jwt.issuer.example.com which specify that a DID document with the public key is available at https://jwt.issuer.example.com/.well-known/did.json, and
  • truly decentralized DIDs like did:iota:2304aa97:0x139b54aa5e5601e7950014b2906d410fd26a6bc271d5f77499b7c55351e23f79 which tell you that a specific resolver is necessary to get to the DID document containing the public key.

Bob was not convinced that the coverage of DIDs was sufficient. Even in flows like OpenID Connect (OIDC) where SSI integration progresses steadily, a mix and match of DIDs and common key identification is to be expected. In traditional JWTs, the issuer would look like https://jwt.issuer.example.com, i.e., a basic HTTP-based URL.

While Bob poured himself and Alice a fresh cup of coffee, he realized that they already discussed HTTP-based key identification in the JWT header part: they would just need a JWKS and a key ID to look for a key in. So his research for JWKS revealed the canonical location for JWKS to be <URL>/.well-known/jwks.json. Okay, JWKS found, but how to identify a key within?

This time, Alice offered that they could just look for the kid header if it was present but they lacked a jku header before. And of course, if that failed, looking for the subject claim would also be worth a try. It felt awfully random if this would actually yield a key but at least one of Alice’s test cases actually succeeded that way.

What to make of subjects and audiences

As they already considered self-issuance of JWTs before, Bob soon realized that they could treat the subject just like the issuer if it was a DID. If it yielded a public key that verified the signature, fine, it was obviously issued by the subject about itself. Another one of Alice’s examples now passed.

Alice made a mental note of warning that while this kind of verification proves what it should, verifying a JWT payload’s signature, it also makes it quite obvious that a verified JWT signature should not be used for authentication or authorization purposes. At least not if no third party double checks that Alice can claim that Alice is an admin and should be able to perform any operation she wants to do.

After considering the subject, they looked at the audiences claim that usually consists of URLs too (or DIDs, for that matter). So they decided they would treat it just as they treated the issuer. Better to be safe than sorry, even though it felt wrong. After prototyping this, they were relieved that it did not increase success rates on Alice’s example. No JWT issuer seemed to rely on that cursed a behavior. Alice hoped for the best, when she suggested, “let’s feature-gate this resolution strategy and remove it when no examples requiring this pop up in production”. Bob face-palmed but tended to agree. You cannot be too pessimistic about people going to lengths when interpreting specs to fit their use cases.

Key Sets are not necessarily .well-known

Among the examples Alice uncovered Bob found a JWT referencing one of the internet’s big players’ OpenID configuration. Therefore, Alice suggested to have another look at OpenID standards to see if they missed an obvious way for public key resolution. After all, they were relying on OIDC standards for a while.

Their research led to the uncomfortable truth that in terms of OpenID there is another .well-known endpoint at play that can influence public key resolution: <URL>/.well-known/openid-configuration. The OpenID configuration returned by this endpoint in turn may contain a jwks_uri field which points to the URL of the JWKS to be used for public key retrieval. Combining this with the previous matrix to probe, Alice and Bob now compiled the following list for JWT verification when encountering OpenID JWTs:

  • look for jwk header
  • look for kid in the JWKS at:
    • jku header field
    • <iss> URL registered claim with /.well-known/jwks.json
    • OpenID configuration at <iss>/.well-known/openid-configuration and then follow the jwks_uri field
    • both of the above for the sub registered claim instead of iss
  • look for the kid or the default keys of DIDs if
    • iss is a DID or
    • sub is a DID

Considering that they did not even include certificate chains and other means of producing JWT signatures, this was already more complex than they wanted it to be. But they ran their prototyped implementation against Alice’s examples and at least there were no more resolution failures except one.

When the DID method influences the outcome

Bob was a bit stymied with that last failure. Everything should resolve. Actually, that example looked just like the did:web in iss claim case. Something that worked in other examples. It wasn’t until Alice looked into their DID resolution notes again that they discovered their oversight: keys in non-ephemeral DIDs are not static. It was one of the key reasons to use non-ephemeral DIDs in the first place.

A did:web is just a container with a defined address, vetted by the trust placed in TLS. But in its DID document, the keys may change. They may be rotated out, reset if a deployment is replaced, or swapped out by an attacker if not properly secured via other means. Regardless of the reason, the key of Alice’s example JWT was not present in the DID document anymore. Even though the JWT is yet to expire.

Fortunately, this just meant that their test suite can ignore the test. After all, a revoked / rotated key should not be used for verification anyway. This example just made them wonder if there were other properties of self-sovereign identities that influenced JWT handling apart from key rotation.

After giving it some thought, Alice concluded that most of the core properties in terms of advantages and disadvantages of non-ephemeral DIDs are actually pretty much the same as you would get with the JWKS URL combined with a kid. In fact, some resourceful developers actually turned that concept into a DID method of its own: did:jwks (note the trailing s).

With the peace of mind that they made a real effort to understand various ways of resolving public keys for JWT verification, Alice and Bob decided to conclude their session for the day. It’s been quite a journey already.

Using filancore Sentinel to verify your JWTs

Just like in their last adventures, Alice and Bob have reinvented a few wheels for educational purposes. If you do not want to reinvent as much, consider using filancore Sentinel which provides:

  • a JSON Web Key Set for your DIDs
  • DID-compatible OIDC login flows (SIOPv2, OID4VP)
  • JWT verification for a wide variety of JWT signatures (DID or traditional)
  • DID-signed JWTs that others understand (using the aforementioned JWKS)
  • secure key management for DIDs in hardware security modules (HSMs)