JWT (JSON Web Token) is an open standard (RFC 7519) that defines a way to provide information within a JSON object between two parties. This standard is intended to help transmit information securely, but no standard or technology will protect you when used improperly.
To identify what can go wrong when using JWT in Node.js, I performed a security review on npm modules that use the most popular JWT libraries. Using static analysis tooling, I examined 2,000 npm modules for security weaknesses and vulnerabilities. This post summarizes some common mistakes that were found during my research, including:
Hardcoded secrets
Allowing the
none
algorithm for signingMissing or incorrect token validation
Sensitive data exposure
In addition to describing these issues so you can avoid them, this post includes open source rules that make it easier to either manually audit your code bases to detect them, or include in CI so these vulnerabilities never get merged into your code in the first place.
Hardcoded secrets
The most basic mistake is using hardcoded secrets for JWT generation/verification. This allows an attacker to forge the token if the source code (and JWT secret in it) is publicly exposed or leaked.
Not only does this introduce a vulnerability, it’s also considered a software anti-pattern. You should keep your JWT secrets apart from your code, for example, in separate configuration files or environment variables.
const jwt = require("jsonwebtoken");
const secret = "hardcoded-secret-here"; // 😈😈😈
class JwtAuthentication {
static sign(obj) {
return jwt.sign(obj, secret, {});
}
}
It’s worth mentioning that a popular way to use JWTs is within other libraries, e.g., through Passport, a popular authentication middleware for Node.js.
var JwtStrategy = require('passport-jwt').Strategy,
ExtractJwt = require('passport-jwt').ExtractJwt;
var opts = {
secretOrKey:'hardcoded-secret-here'; // 😈😈😈
}
passport.use(new JwtStrategy(opts, function(jwt_payload, done) {
// code
}));
Even though this is a known and quite obvious issue, it’s still common to use hardcoded secrets while developing and then accidentally leave it in your codebase. Fortunately, it’s also very easy to find hardcoded secrets with SAST tools, especially with Semgrep, which helps to find complex code patterns with rules that are very simple to write. Rules for detecting hardcoded secrets:
https://semgrep.dev/editor?registry=javascript.jsonwebtoken.security.jwt-hardcode
https://semgrep.dev/editor?registry=javascript.passport-jwt.security.passport-hardcode
https://semgrep.dev/editor?registry=javascript.express.security.express-jwt-hardcoded-secret
Allowing 'none' algorithm for signing
Allowing tokens to have the 'none' algorithm was a critical vulnerability some years ago. Nowadays, most popular JWT libraries do not allow decoding or verifying tokens with the None algorithm without explicitly enabling it. The same as with hardcoded secrets, it’s easy to leave ‘'none'` in your codebase after testing or debugging.
let jwt = require("jsonwebtoken");
let secret = "some-secret";
jwt.verify("token-here", secret, { algorithms: ["RS256", "none"] }); // 😈 'none' allowed
Anyway, if you forget to remove it after messing with code, it’s also very easy to catch it with Semgrep. Rules for detecting ‘none’ algorithm allowed in your code:
https://semgrep.dev/editor?registry=javascript.jose.security.jwt-none-alg
https://semgrep.dev/editor?registry=javascript.jsonwebtoken.security.jwt-none-alg
Not verifying tokens the right way
Sometimes developers rely on their methods of token verification instead of using built-in API, or omit verification completely. Small wonder that usually it introduces the opportunity for attackers to forge information inside the token.
const jwt = require("jsonwebtoken");
const checkToken = (token, refreshToken, key) => {
if (jwt.verify(refreshToken, key)) {
// 😈 only `refreshToken` verified
return jwt.decode(token).param === jwt.decode(refreshToken).param;
}
return false;
};
Note: only refreshToken
is verified in the example above, which gives an opportunity to attacker to manipulate function results. By changing value of param
property stored inside token
an attacker can force the result of checkToken
function to be true
and pass the verification.
On top of that, it’s very typical to get certain data from tokens before verifying it (issue date, id, etc.) and then use it as a verification context. Usually it’s harmless, but only if this data does not go any further. If the information from an unverified token is passed to other parts of the code it may introduce a vulnerability.
// token verification logic
const jwt = require("jsonwebtoken");
function checkToken(token) {
const issuer = jwt.decode(token).issuer;
if (findIssuer(issuer) && jwt.verify(token, key)) {
// code here
} else {
throw new Error("not valid token");
}
}
// database utility from different module
function findIssuer(iss) {
// ...
database.find(iss);
}
(In the example above, the unverified issuer
value is passed to another function before validating the token (https://owasp.org/www-project-top-ten/OWASP_Top_Ten_2017/Top_10-2017_A1-Injection). If not used carefully it may end up in different kinds of injection vulnerabilities, especially if located in separate parts of the codebase.)
Also do not forget that even if a token is verified properly, the data stored in it should be treated as user input and be validated and sanitized according to the context.
Rule that helps identify lack of token verification:
https://semgrep.dev/editor?registry=javascript.jsonwebtoken.security.audit.jwt-decode-without-verify
Sensitive data exposure
When an object is converted to a JWT token without explicitly breaking it down into parts, it’s very easy to lose control of what is inside the object and disclose some sensitive information.
This is a very widespread mistake while using ORM libraries like Mongoose, Sequelize, etc. ORM models do not include any sensitive data at the moment of creation, but when the situation changes it is very easy to forget that the ORM object is also passed to JWT token.
// Mongoose model
const mongoose = require('mongoose'),
Schema = mongoose.Schema;
const schema = new Schema({
name: String,
password: String,
admin: Boolean
});
const User = mongoose.model('LocalUser', schema);
// Express controller
router.post('/signin', (req,res) => {
User.findOne({name: req.body.name}, function(err, user){
var token = jwt.sign(user, key, {expiresIn: 60*60*10}); // 😈 passing User object directly to JWT
res.json({
success: true,
message: 'Enjoy your token!',
token: token
});
});
}
Needless to say, you should not keep sensitive data in JWT token intentionally.
Helpful Semgrep rules:
https://semgrep.dev/editor?registry=javascript.jsonwebtoken.security.audit.jwt-exposed-data
https://semgrep.dev/editor?registry=javascript.jose.security.jwt-exposed-credentials
https://semgrep.dev/editor?registry=javascript.jsonwebtoken.security.jwt-exposed-credentials
These are the most common mistakes developers make when using JWT in their Node.js projects. Stay secure and don’t forget to automate security scans in your codebase.
Resources
Publicly disclosed report that was found during research (more coming soon): https://hackerone.com/reports/748214
More insight on JWT security and best practices:
Autho0: Critical vulnerabilities in JSON Web Token libraries
JWT: jku x5u - NahamCon 2020 talk by Louis Nyffenegger
JWT Parkour - AppSec California 2020 talk by Louis Nyffenegger
Are You Properly Using JWTs? - AppSec California 2020 talk by Dmitry Sotnikov
More Semgrep rules: https://semgrep.dev/r