Hardcoded secrets, unverified tokens, and other common JWT mistakes

by Vasilii Ermilov

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 signing
  • Missing 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

More insight on JWT security and best practices: