Introduction
Json Web Token is a commonly used standard defined in RFC 7519 for secure data transfer between services. It provides data integrity, meaning that a service, upon receiving a token, can verify, if that token made its way between the sender and the receiver without being tampered with. It can also provide confidentiality, if the token was encoded into a JWE (Json Web Encryption) structure. During web development there is a tendency to outsource the implementation of JWT to third-party packages, trusting that they are correct and safe in what they are doing, allowing developers to focus on business logic. Today we are going to explore how that trust can have devastating consequences, and that any package that we are using that is supposed to provide some form of security, should be diligently tested and understood.
Recap
JWT is a string built from three elements, separated by dots, each encoded with base64:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlN0ZXZlIiwicGsiOjEsImlhdCI6MTYyNTc3MjM2NH0.rEyrqFY2p_039rkeycCi121m57hihC044ZXUtMEKOKo
Header specifies the type of the token, and the algorithm that is going to be used to create a signature:
{ "typ": "jwt",
"alg": "HS256"
}
Payload has the data, important from the standpoint of the functionality for which the communication between services is occurring. An example could be the user who is sending the request to a server. Payload identifies that user:
{"username": "Steve", "pk": 1}
Signature guarantees data integrity. It is created by the following operation:
header.alg(base64encode(header) + "." + base64encode(payload))
The data integrity is provided because the server signs the data using some private key, and
sticks that signature as the last portion of the token. If an attacker change the payload along the way, the server, upon verifying the signature, will not verify it correctly. Change of the signature to match the changed payload is not possible by the attacker, because only the server knows the private key that was used to create that signature.
The idea is straightforward, yet a substantial amount of complexity is required in order to properly implement such a package. Let’s explore what could go wrong.
The “none” algorithm
The RFC specifies that it is possible to pass “none” as a value of “alg” key in the header. This can be used for example when the data is transferred between the services that are hosted on some private network. From RFC:
To support use cases in which the JWT content is secured by a means
other than a signature and/or encryption contained within the JWT
(such as a signature on a data structure containing the JWT), JWTs
MAY also be created without a signature or encryption. An Unsecured
JWT is a JWS using the “alg” Header Parameter value “none” and with
the empty string for its JWS Signature value, as defined in the JWA
specification [JWA]; it is an Unsecured JWS with the JWT Claims Set
as its JWS Payload.
If the package that we rely on for incorporating JWT protocol does not take that into account, a couple of things could happen, depending on the implementation – the server will return the response with status code 500, or, it could just completely ignore the fact that token was sent without any signature, don’t verify it (because “alg” header told him not to), and return any sensitive data that this request was crafted for.
HMAC using Public Key
Services tend to give away their public keys. If they support JWT protocol, it is intended so that the client can verify the token himself. JWT that is being sent to the server is then typically expected to have specified an algorithm that reflects that situation, such as RS256. But in an improperly configured / programmed ecosystem, the client can specify “alg” as HS256 in the header, and sign it using the server’s public key, and then expect that the server is going to verify that token using HMAC, and the same public key. This verifies successfully, but completely circumvents any data integrity, and the security that JWT is supposed to provide. Example:
const fs = require('fs');
const jwt = require('jsonwebtoken');
// Public key previously collected from the target
const publicKey = fs.readFileSync('./public.key', 'utf8');
// Any user of attacker’s choosing
const payload = {
"username": "admin",
"pk": 0,
}
// Forge a token using target’s public key, but use algorithm that
// generates signature using symmetric key. Hopefully server will
// do the same.
let forged = jwt.sign(payload, publicKey, { algorithm: 'HS256'})
Token verification vs. token encoding
Another problem may arise if an incorrect method is being used upon verification of the token by the server. If JWT is just decoded from base64, and data inside the header and the payload are valid, that does not mean anything more than that – the data is valid. What this does not tell us is whether it was the entity claimed as in the payload actually sent it, or if the data inside the token wasn’t changed. This is what token verification is about. A piece of code that checks the token’s signature. This mistake can happen not only on the library code, but also on the backend server, by a programmer who is using the functions from such a library. If only the decoding function is being used, the server is exposed to anyone, and no security is actually being provided. Though it might be obvious, it could simply be overlooked.
Final thoughts
As we can see, our assumption that we can rely on third-party software to provide security and data integrity is correct only as long as that software itself is correct. We have explored that this is not always the case. Should you drop using third-party packages for security solutions? Probably not, but you at least should be aware of what could go wrong. Use the latest versions, check if your current versions do not have any vulnerabilities, and if they do, try to upgrade them. If you are implementing means of security by yourself, using any functionality from a third-party package, make sure you are using it as intended.