42CRUNCH BLOG


7 Ways to Avoid JWT Security Pitfalls


Dec 22nd 2021.  Author: Dr. Philippe de Ryck, Pragmatic Web Security,

 

Philippe also presents on our OWASP API Security Top 10 webinar series starting in January 25th, 2022. Register

OWASP API Security top 10 challenges webinar series

 

Like them or hate them, JSON Web Tokens (JWT) are everywhere.

OAuth 2.0 and OpenID Connect rely heavily on JWTs. Many applications use JWTs to implement custom security mechanisms. And every language or framework offers plenty of support for JWTs.

Unfortunately, JWTs also lie at the heart of numerous API security failures. Handling JWTs securely is often challenging and error-prone. And because JWTs are typically used for sensitive features, such as authentication and authorization, the impact of such failures can be quite critical.

Plenty of articles (https://pragmaticwebsecurity.com/articles/apisecurity/hard-parts-of-jwt.html) discuss the technicalities of JWTs, but not too many focus on concrete developer guidelines to avoid security pitfalls. In this article, we cover seven techniques that will help you avoid JWT security pitfalls.

 

1. Use a Static Algorithm Configuration

In 2015, the JWT world was shaken up by the disclosure of a JWT type confusion attack (https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). In such an attack, the application expects a token signed by a private key, but can be confused to accept a token protected by an HMAC. As a result, an attacker can craft arbitrary tokens that are accepted as valid tokens, typically resulting in an authorization bypass.

In hindsight, this problem is quite silly to have, but hard to eradicate. Even to this day, applications still suffer from similar vulnerabilities. The problem is that many applications use the alg claim before the token is verified, allowing the attacker to tamper with that value.

So what’s the best way to avoid such issues?

Do not trust the value of the alg header claim. That’s it.

 

In most cases, the application knows the exact signature algorithm it expects. As a result, the algorithm can be hardcoded, as shown in the example below.

// Example using the ‘jsonwebtoken’ library for Node

const signedToken = jwt.sign(data, signingKey, { algorithm: “PS256” });

When the application cannot hardcode the algorithm, it should maintain a list of acceptable values (e.g., [“RS256”, “PS256”]. The application can then verify the value of the alg claim against this list.

Note that the list should either contain HMAC algorithms or asymmetric algorithms, but avoid mixing them together. This report (https://github.com/firebase/php-jwt/issues/351) for the Firebase php-jwt discusses how handling both types in the same code path can still result in confusion. So in the rare case that you need to handle both HMACs and signatures simultaneously, make sure you split that functionality into separate code paths.

Key takeaway: a token verification procedure should only accept a single type of token.

 

2. Use Explicit Typing

Take a look at the decoded JWT header and payload in the snippet below. Can you tell what this JWT is used for?

{

  "alg": "PS256",
  "typ": "JWT",
  "kid": "NTVBOTNjEyMw"

}
{
  "iss": "https://sts.example.com/",
  "sub": "auth0|60adf0e02cda61006e4a9110",
  "aud": "https://api.example.com",
  "iat": 1638283665,
  "exp": 1638370065,
  "azp": "DtsTliLAWq3JXIwaoPQzl8vXhNI6qGnb",
  "scope": "openid email offline_access"
}

The JWT shown here is an OAuth 2.0 access token. You may have guessed this correctly because you’re pretty familiar with access tokens. Otherwise, there’s no way to determine the purpose of a JWT. And precisely that lack of information can lead to a problem known as token confusion.

What is token confusion all about?

Let’s explore with another example. What’s the purpose of the JWT shown below?

{
  "alg": "PS256",
  "typ": "JWT",
  "kid": "NTVBOTNjEyMw"
}

{
  "email": "philippe@pragmaticwebsecurity.com",
  "email_verified": false,
  "iss": "https://sts.example.com/",
  "sub": "auth0|60adf0e02cda61006e4a9110",
  "aud": "DtsTliLAWq3JXIwaoPQzl8vXhNI6qGnb",
  "iat": 1638283665,
  "exp": 1638319665
}

This time, the token is an OpenID Connect identity token. Client applications typically request an identity token and access token from an OAuth 2.0 authorization server. The identity token contains information about the authenticated user. The access token is used to access APIs on behalf of the authenticated user.

Depending on the authorization server’s configuration, these tokens can look eerily similar. As a result, an API may be tricked into accepting an identity token as an access token, which can result in authorization bypass attacks.

To avoid this problem, JWT tokens should be explicitly typed. The snippet below shows our access token from before, but with an explicit purpose in the typ header claim.

{
  "alg": "PS256",
  "typ": "at+jwt",
  "kid": "NTVBOTNjEyMw"
}

{
  "iss": "https://sts.example.com/",
  "sub": "auth0|60adf0e02cda61006e4a9110",
  "aud": "https://api.example.com",
  "iat": 1638283665,
  "exp": 1638370065,
  "azp": "DtsTliLAWq3JXIwaoPQzl8vXhNI6qGnb",
  "scope": "openid email offline_access"
}

Explicitly including the purpose in the claims removes any ambiguity about the meaning of the token. APIs can be configured to only accept tokens with the at+jwt type, preventing an attacker from using any other token type.

Note that the value of the typ header is an arbitrary string. Consequently, you can use any type you prefer for custom-generated JWTs. The token below shows the header of an email verification token with a custom type.

{
  "alg": "PS256",
  "typ": "email-verification+jwt",
}

Key takeaway: when generating JWTs, include an explicit type in the typ header claim.

 

3. Go All Out on Metadata

The JWT specification lists several reserved claims with a specific meaning. Some of these claims are crucial to determine the validity of a JWT. For example, a JWT token can have an expiration date (exp), a not-before date (nbf), and a time of issuing (iat). Most developers are well-aware of these claims, and most libraries automatically verify them when checking the signature of a JWT.

Apart from these time-based claims, there are two more reserved claims with a critical role for security: the issuer (iss) and the audience (aud).

The iss claim indicates the identity of the issuer of a JWT. The value is an arbitrary string, but URL-based identifiers are commonly used as the value. For example, in an OAuth 2.0 access token, the value of the issuer is the URL of the authorization server.

The aud claim indicates the identity of the party supposed to consume the JWT. As the issuer, the value is an arbitrary string, but URL-based identifiers are pretty standard. In our access token example, the aud claim indicates that this token is supposed to be consumed by our backend API.

Unfortunately, having these claims in the token is useless if nobody checks them. In our access token example, the API should ensure that the values of the iss and aud claims are valid. A failure to do so can result in authorization failures.

But how does a scenario like that happen?

 

Cases like this are pretty common in complex architectures, where multiple APIs accept tokens coming from the same issuer. A good example is an API ecosystem accepting access tokens from the same authorization server, as illustrated below.

The snippet below shows a token that represents the authority to access API 1, but not API 2, as indicated by the aud claim.

{
  "iss": "https://sts.example.com/",
  "aud": "https://api1.example.com",
  ...
}

Both APIs expect tokens from the authorization server, so the signature verification step will succeed. However, when these APIs fail to verify the audience claim, a malicious user or client can trick API 2 into performing operations with a token intended for API 1.

Avoiding this issue is not that complicated. Most JWT libraries support the verification of the issuer and the audience out of the box. All it takes to enable this behavior is providing the library with the expected value, as shown in the snippet below.

// Example using the 'java-jwt' library for Java 
Algorithm algorithm = Algorithm.RSA256(publicKey, null);
 JWTVerifier verifier = JWT.require(algorithm)
     .withIssuer("https://sts.example.com/")
     .withAudience("https://api1.example.com/")
     .build(); DecodedJWT jwt = verifier.verify(token);

 

Key takeaway: always include and verify the target audience of a JWT.

 

4. Treat your Secrets as Secrets

To sign a JWT, the application requires access to a secret. Similarly, verifying an HMAC on a JWT requires access to the HMAC secret. Intuitively, we all know that secrets should be kept secret. Unfortunately, the current state of practice tells a different story.

As illustrated by various studies, it turns out that secrets are often hardcoded in applications (https://r2c.dev/blog/2020/hardcoded-secrets-unverified-tokens-and-other-common-jwt-mistakes/). As a result, the secret is easily exposed, which can lead to the total compromise of an application.

Even worse, some applications simply re-use a hard coded secret from examples found in documentation or Stack Overflow. By observing a single JWT, an attacker can quickly recover such a publicly-known secret using tools such as Burp’s JWT Heartbreaker extension (https://portswigger.net/daily-swig/jwt-heartbreaker-offers-remedy-for-weak-json-web-tokens).

This problem can be avoided altogether by treating secrets as secrets. First, secrets should be freshly generated and not be shared across dev and prod environments.

Second, secrets should be provided to the application without hardcoding them. A simple approach is to provide secrets as environment variables. Even better is to rely on the secret management service of your platform (e.g., AWS Secrets Manager, Azure Key Vault) or an external service, such as Hashicorp’s Vault (https://www.vaultproject.io/).

Key takeaway: do not include HMAC secrets or private keys in code.

 

5. Bring Down the Testing Hammer

Even with the best intentions, it is still easy to make mistakes when handling JWTs. This vulnerability in Apache Pulsar is the perfect example (https://portswigger.net/daily-swig/apache-pulsar-bug-allowed-account-takeovers-in-certain-configurations). In this vulnerability, the application accepted tokens with the alg claim set to none. This effectively means that the token is unsigned, allowing an attacker to craft valid tokens with arbitrary claims. In this particular case, the vulnerability resulted in an authorization bypass.

So, what happened here?

 

As you can see, the mistake occurred in a single line of code. The application used the parse function of the JJWT library, instead of the parseClaimsJws function.

Of course! What a silly mistake.

In reality, things are not that simple.

The documentation of the parse function (https://github.com/jwtk/jjwt/blob/master/api/src/main/java/io/jsonwebtoken/JwtParser.java ) does not explicitly mention this insecure behavior. Additionally, the library has five other functions that can be used to process a JWT. In a nutshell, it is virtually impossible for a developer to figure this out while writing code.

That’s why we need testing!

Every API endpoint that accepts JWTs should be rigorously tested to ensure it handles JWTs securely. And by rigorously, I mean bringing down the hammer!

Here’s a list of invalid JWTs that a test suite should throw at any JWT verification function:

  • A JWT signed with the wrong key
  • A JWT with alg: none
  • A JWT with alg: nOnE (to bypass case-sensitive checks)
  • A JWT with an HMAC using the public key as the secret (see https://pragmaticwebsecurity.com/articles/apisecurity/hard-parts-of-jwt.html)
  • A JWT with the wrong algorithm (e.g., RS256 instead of PS256)
  • A JWT with the wrong typ header
  • A JWT with an invalid iss value
  • A JWT with an invalid aud value
  • A JWT with an exp timestamp in the past
  • A JWT with an nbf timestamp in the future
  • A JWT with an iat timestamp in the future

Each of these tests should result in an authorization error. If one of these tokens is accepted, there’s likely a significant vulnerability in the application under test.

Note that many of these tests rely on a JWT with a valid signature. Often, the application relies on a key pair that is out of your control (e.g., a signing key from a cloud provider). One trick to sidestep that limitation is to use monkey-patching to override how the application retrieves the public key, so you can use a testing key pair to generate “valid” tokens. The code example below shows how to do this with a Python test fixutre.

@pytest.fixture(autouse=True)

def mock_signing_key(monkeypatch):
    def return_mocked_key(issuer):
        return "-----BEGIN RSA PRIVATE KEY-----\nMIIEowI..."
        monkeypatch.setattr(authutils, "get_signing_key", return_mocked_key)

Key takeaway: use JWT-specific tests to ensure the application behaves as you think it does.

 

6. Encapsulating Security Behavior

At this point, we should start wondering whether it is realistic to expect developers to apply all of these guidelines each and every time they handle a JWT.

Spoiler alert: it is not!

That’s why number six takes it to a different level. What if we encapsulated all of these JWT handling best practices in a clean and simple application library? Wouldn’t that make this a lot easier to get right and thus more secure?

Concretely, the library is responsible for verifying the validity of a JWT. If the JWT is valid, the library makes the payload claims available to consume by the rest of the application.

This pattern absolves individual developers of the responsibility of handling JWTs but still allows them to use the data in the JWT when necessary.

The exact mechanism to encapsulate JWT handling depends on the framework you’re using. For example, in the Java Spring Boot ecosystem, all of the JWT details can be encapsulated in a custom JwtDecoder instance. In a Python Flask project, you can implement JWT handling in a custom decorator.

Finally, encapsulation offers an additional benefit: if you ever want to replace JWT tokens with an alternative token format (https://www.scottbrady91.com/jose/alternatives-to-jwts), you only need to update the library that verifies the token and loads the claims. The rest of the application remains unchanged.

Key takeaway: encapsulate JWT handling in an easy-to-use system library.

 

7. Rely on Static Code Analysis

Applying all of the best practices discussed in this article will definitely improve the security of your APIs. But once you get there, how certain are you that it will stay that way? What if someone uses the wrong parsing function in a new snippet of code?

That’s where static code analysis can play a crucial role. Static analysis has become an essential part of any appsec program and should be present in every build pipeline. Open source tools, such as Semgrep (https://semgrep.dev/), and commercial tools, can quickly alert you of potentially insecure patterns in your code.

Not all tools come equipped with a long list of JWT security anti-patterns to identify. However, many tools can easily be customized and extended. Therefore, it is recommended to analyze common pitfalls in the JWT library you are using and ensure that they are detected by static code analysis.

For example, if you are using the JJWT library, you should have a pattern that flags the parse function. If you are using PyJWT, there should be a rule that looks for verify_signature=False.

Covering all JWT pitfalls we discussed here with static analysis rules is not a simple and effortless task. However, it gives you the assurance that your codebase avoids these pitfalls and that any future mistakes will be caught immediately. In the end, it’ll be worth it!

Key takeaway: rely on static code analysis to avoid common JWT pitfalls.

 

Summary

And that brings us to the end of this article. To summarize, let’s take one last look at the seven takeaways to avoid JWT security pitfalls:

  1. A token verification procedure should only accept a single type of token
  2. When generating JWTs, include an explicit type in the typ header claim
  3. Always include and verify the target audience of a JWT
  4. Do not include HMAC secrets or private keys in code
  5. Use JWT-specific tests to ensure the application behaves as you think it does
  6. Encapsulate JWT handling in an easy-to-use system library
  7. Rely on static code analysis to avoid common JWT pitfalls

If you’re looking for more content, don’t hesitate to check out the JWT Best Current Practices (https://datatracker.ietf.org/doc/html/rfc8725.html) or OAuth 2.0’s JWT profile for Access Tokens (https://datatracker.ietf.org/doc/html/rfc9068).

Philippe also presents on our OWASP API Security Top 10 webinar series starting in January 25th, 2022. Register

OWASP API Security top 10 challenges webinar series

 

Finally, check out Philippe’s security cheat sheets, including a handy overview of JWT security guidelines (https://pragmaticwebsecurity.com/cheatsheets.html).