JWT Security: 6 Common Failures in Open Source
The "Tutorial Trap" Hall of Fame
1. The "Secret" Secret
I found three separate projects using literally
"secret" as their signing key. const token = jwt.sign({ userId: user.id }, "secret", { expiresIn: "1h" });A 6-character ASCII string provides about 42 bits of entropy. A GPU cluster will chew through a dictionary attack on this in milliseconds. If your secret is a word found in a dictionary, you don't have a secure token; you have a polite suggestion of security. Use a 256-bit cryptographically random key.
2. Using jwt.decode() for Authorization
This is a classic "it works in testing" disaster. I saw this in actual production middleware:
// DANGEROUS: No signature verification
const decoded = jwt.decode(req.headers.authorization.split(" ")[1]);
if (!decoded.userId) return res.status(401).send("Unauthorized");jwt.decode() just Base64-decodes the string. It doesn't care if the signature is valid, expired, or written in crayon. An attacker can forge any payload they want, and this code will happily let them in.
The Fix: Use jwt.verify().
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });3. Leaving the Algorithm to Chance
Even when using
verify(), many skip the options object:// Missing algorithms option
jwt.verify(token, secret);If you don't explicitly define { algorithms: ['HS256'] }, the library trusts the alg header inside the token. A savvy attacker can change the header to alg: none and send an empty signature. Some libraries will see "none," see that the signature is indeed empty, and declare the token valid. It's essentially letting the intruder choose the lock for your front door.
4. Git-Committing the "Secret"
Nothing says "professional" like a config file committed to a public repo that looks like this:
// Found in config.js, committed to a public repo
module.exports = {
jwtSecret: "productionsecretdonotshare2024",
database: process.env.DATABASE_URL
};The developer even added a comment saying "do not share." The irony is palpable. Once a secret hits Git history, it's compromised forever. Deleting the line in a new commit doesn't help; the secret is still sitting there in the commit history for any bot to scrape.
5. LocalStorage Obsession
The frontend "standard" seems to be dumping tokens into
localStorage:localStorage.setItem("token", response.data.token);localStorage is a playground for XSS. One unsanitized input field or one compromised npm dependency, and your user's token is exfiltrated to a remote server. Switch to httpOnly cookies. Since JavaScript can't touch them, XSS attacks can't steal them.
6. The Immortal Token
I saw multiple implementations completely omitting the
expiresIn parameter:// No expiresIn — this token is basically a lifetime achievement award
const token = jwt.sign({ userId: user.id }, secret);A JWT without an exp claim never expires. If that token leaks into a log file or a browser cache, it's valid until the end of time (or until you rotate the global secret and kick everyone out). Set access tokens to 15 minutes and implement a proper refresh token pattern for long-lived sessions.
The "Don't Get Hacked" Checklist
If you're doing a deep dive into your own AI workflow or building a real-world LLM agent that handles auth, run through this:
- Entropy: Is the secret 256-bit and generated via CSPRNG?
- Environment: Is the secret in a
.envfile (and.gitignore-ed), not inconfig.js? - Verification: Is
jwt.verify()used exclusively for auth checks? - Explicit Algs: Does every
verify()call include{ algorithms: ['HS256'] }? - Storage: Are you using
httpOnlycookies instead oflocalStorage? - Expiration: Does every token have a reasonable
expclaim?