JWTs And Their Pitfalls
on nevi.devThis 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.
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.
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.
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
Pronounced “jot”, as officially suggested by the standard. ↩︎
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 quickbase64 -d
. ↩︎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. ↩︎