So you want to verify a JWT – SSI edition
Alice and Bob used to love JSON web tokens (JWTs). Services issued them, they fed them into some library which validated and verified them and they could simply use the encoded claims. As they immerse into the SSI world, they are shocked to discover how many paths lead beyond what common verification tooling reliably implements.
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 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):
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022
}
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:
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:
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.
Recommended by LinkedIn
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:
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:
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:
Here’s the link to the blog post on our website: https://filancore.com/blog/jwt-verification