JWTs And Their Pitfalls

on nevi.dev

This piece was originally posted on the Mathpresso Team Blog.

JSON Web Tokens, or JWTs1 for short, are self-contained tokens that are often used in the context of authentication and authorization. In this post, I will explain how JWTs work, what problems they are meant to solve, and common pitfalls to avoid when implementing a service that uses JWTs.

A Crash Course on JWTs

A JWT is just a JSON object, base64-encoded2 and signed. They generally look like this:

eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

After decoding each dot-separated section, we obtain the following:

{
  "typ": "JWT",
  "alg": "HS256"
}.{
  "iss": "joe",
  "exp": 1300819380,
  "http://example.com/is_root": true
}.<some binary data>

The first section is the “JOSE header”, and contains metadata about the token itself, such as the type of the token (typ), the signing algorithm used (alg), and an identifier for the signing key (kid), among others.

The second section is the payload, the actual JSON object we are storing in the token. It can contain information about the token’s issuer (iss), its expiry date (exp), other application-specific data (like http://example.com/is_root), and more. Each key in this JSON object is called a “claim”, and some claims (like iss and exp) have been standardized and assigned a meaning. For a more complete reference on standard claims, refer to the IANA JSON Web Token registry.

The last section, the signature, is generated by running the signature algorithm specified in the alg header parameter. This field is used to make sure that the JWT has been created by a trusted party and has not been modified by anyone else, which is fundamental in the security and integrity of any system that uses JWTs.

An Example Auth Application Using JWTs

So what’s the point of using JWTs? To better understand how JWTs tackle the problem of scalability with authn and authz, let’s examine an example application that uses JWTs.

jwt sequence diagram

Assuming we are using an asymmetric signature algorithm, service A holds the private signing key and publishes the public verification key for everyone. The user, who is already authenticated to service A, requests a new token from service A for use at service B, which service A then creates and signs for the user. The token may look like this:

{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "2021-02"
}.{
  "iss": "https://a.example.com",
  "aud": "https://b.example.com",
  "iat": 1613458948,
  "nbf": 1613458948,
  "exp": 1613460748,
  "sub": "nevivurn"
}.<signature>

The user then sends this signed token to service B, which verifies the token using the public key identified by the ID “2021-02”. If the verification succeeds, service B can be certain that the requester is the user “nevivurn”, without having to consult service A or a shared database.


Common Pitfalls

JWTs can be very secure if implemented and used properly. However, we must be careful to avoid these common pitfalls when implementing a system that uses JWTs for security.

Accepting "alg": "none"

The JWT standard allows the use of the none algorithm for signature. This algorithm does… you guessed it, nothing. It is insecure, and should never be used in production services.

The problem arises when the verification logic is implemented in the following manner:

def verify_token(token, key):
  if token.header.alg == 'HS256':
    return verify_hs256(token, key)
  elif token.header.alg == 'RS256':
    return verify_rs256(token, key)
  ...
  elif token.header.alg == 'none':
    return True

At first glance, this implementation seems sane, until we realize that the value of alg parameter is not verified in any way or form, and an attacker can choose this value as they see fit. If the verification logic were to be implemented as above, this would allow the attacker to create arbitrary tokens that would be accepted as valid by our service, completely breaking any security guarantees.

In the same vein, improper validation of the alg parameter may lead to a vulnerability known as “key confusion”, where the attacker sends a token signed with HS256 when the service expects a token signed with RS256. Assuming an implementation similar to the above pseudocode, this may cause the service to interpret the public verification key as the secret key used in HS256 signatures, which the attacker may have access to,3 which allows the attacker to create whatever tokens they desire.

To prevent this entire class of issues, make sure that the JWT library you use properly validates that the token’s alg parameter matches the type of key you expect to validate incoming tokens with. Furthermore, make sure you are using an up-to-date version of a trusted, well-supported, and audited JWT library, and watch out for any security updates.

Using The HS* Signature Algorithms

The HS* signature algorithms are symmetric, in that they use the same key for signing and verification. This means that whoever can verify a token will be able to sign any token they need; everyone in this system needs to be completely trusted, secure, and they must share a shared secret key securely and privately.

symmetric signature

On the other hand, every other signature algorithm is asymmetric, and only requires the issuing party to securely hold on to a private key; the public verification key may be published all over the internet with no significant reduction in the system’s security.

asymmetric signature

As JWTs are usually deployed in the context of microservices, using a symmetric signature algorithm leads to problems in scale, security, and significantly limits the usefulness of JWTs. Use anything other than HS{256,384,512} to avoid these issues.

Storing Secret Data in JWTs

Unless we are encrypting the token, all data in the token’s header or payload is trivially readable by anyone that obtains the token, be it tech-savvy users or malicious attackers. We must avoid storing secret or sensitive data in these tokens, and if we do, we must make sure to encrypt the tokens. However…

Encrypting JWTs

While it is possible to encrypt JWTs, this is not a common configuration and often limits the usability of JWTs, as now we need a mechanism to share a secret decryption key among every party that needs to use the JWT (refer to the issue with HS* signature algorithms).

Furthermore, encrypted JWTs have significantly more levers to pull and algorithms from which we must choose from. Without expert knowledge on the properties and trade-offs offered by each, it is challenging to make the correct set of choices for our particular use case, and with a single mistake, we risk a completely flawed and insecure system.

Now, encrypting JWTs may be the correct choice for your particular use case, but make sure that you need encrypted JWTs before reaching for them.

Not Having a Revocation Plan

The strength of JWTs lies in their statelessness; anyone who has the (public) verification key can read the token without having to consult with anyone else, unlike traditional mechanisms such as server-side sessions. This allows easier scaling of systems using JWTs, and they are a good fit for distributed microservice-based applications.

The stateless nature of JWTs also causes problems, however. In particular, JWTs do not natively support revocation in any meaningful way. We can try to shoehorn in a revocation mechanism, but this inevitably leads to a flawed revocation mechanism or the re-invention of sessions along with their stateful baggage.

This problem is often partially mitigated by using a separate “refresh token”, which may or may not be a JWT, that has a long expiry date. This refresh token can then be used to obtain an “access token” which expires much earlier. This allows a stateless access token flow and a separate refresh flow where mechanisms such as stateful revocation may be implemented. Note that even this is not a complete solution, and we must consider the trade-off between security, performance, and usability before deciding to use refresh tokens.

Fundamental Issues With JWTs

The problems mentioned above are not isolated issues. They point to a larger, fundamental problem in the design of JWTs as a security standard.

Make no mistake; JWTs can be used safely and securely if implemented correctly. The flaw lies in the vastness of the configuration space and the flexibility of the standard, which is not a desirable property if we want a secure-by-default system.

Too Many Choices

Most programmers using cryptographic libraries are not expert cryptographic security evaluators. […] There is a long history of these programmers and users making poor choices of cryptographic primitives, such as MD5 and 512-bit RSA, years after cryptographers began issuing warnings about the security of those primitives. –DJB

Modern cryptographic systems like NaCL and age leave exactly zero decisions up to the developer, and rightly so. The JWT standard, on the other hand, leaves essentially everything up to the application developer, which has led to endless confusion and easily preventable vulnerabilities.

Mixing Different Kinds of Algorithms

The JWT signature algorithm allows for two entirely different classes of algorithms: the symmetric HS* algorithms and the asymmetric everything else. JWTs also have the option of being encrypted, along with its own set of foot-guns and pitfalls, which confuses developers even further. Surfacing these options without making it extremely obvious what the differences between these algorithms are is dangerous: confused developers create confused implementations.

Handling Non-Validated Inputs

Handling non-validated data is the root of all evil. Yet, the JWT standard essentially requires the processing of the token header before any validation, as the header itself contains information on how to validate the token as a whole. This fundamental design flaw leads to issues such as the "alg": "none" and the key confusion attacks mentioned above, among others.


Wrap-Up

JWTs are a powerful solution to the problem of authentication and authorization at scale in microservices. However, the pitfalls created by the fundamental design flaws force us to be very careful in our implementation and use of these tokens. As the lack of widely-supported, standard alternatives leaves us with no choice other than to use JWTs, we have to become familiar with the sharp edges of JWTs.

References


  1. Pronounced “jot”, as officially suggested by the standard↩︎

  2. Specifically, it is base64url-encoded with no padding. base64url is similar to regular base64, but the URL-unfriendly characters "+" and "/" are replaced with "-" and "_", respectively. Note that the "+" and "/" appear in base64-encoded data (assuming ASCII-only input) only if the input contained a "?" or "~" at specific locations. This means that if you are in a hurry, you can usually inspect the ASCII-only sections with a quick base64 -d↩︎

  3. In some cases, this attack may be possible even when the public key is not available to attackers, as shown in this recent blog post↩︎