It started with a throwaway comment in a code review.
I was scanning through a popular Node.js starter template on GitHub — one with over 4,000 stars, regularly featured in "best Express boilerplate" roundup articles. The kind of repo junior developers clone on day one and senior developers use as a starting point for new services. And buried inside config/default.js, I found this:
module.exports = {
jwtSecret: "secret",
jwtExpiry: "7d",
};
Not a placeholder. Not a comment warning you to change it. Just... "secret". Shipped to production in thousands of forks.
I closed the tab, then reopened it. Then I opened eleven more repos.
What I found over the next few hours was not reassuring. The same class of mistakes appeared again and again — not just in beginner repos, but in widely-used templates, production starter kits, and even a few packages with active contributors who clearly knew what they were doing. JWT is one of those technologies where the happy path works fine and the failure modes are invisible until someone is already inside your system.
Here are the six mistakes I found, what they look like in real code, why they matter, and how to fix them.
The 12 Repos: A Quick Note on Scope
To keep this constructive rather than a public callout, I'm not naming repositories directly. Instead I'll describe them by category. The sample included:
- 3 Express + MongoDB boilerplates (2,000–8,000 stars each)
- 2 NestJS starter kits
- 2 full-stack Next.js templates with built-in auth
- 1 Fastify REST API scaffold
- 1 Node.js microservices example repo from a major cloud provider's tutorial series
- 1 React Native + Node backend starter
- 1 GraphQL + Apollo Server template
- 1 open-source SaaS boilerplate with a paid tier
All were JavaScript or TypeScript. All used
jsonwebtokenor a thin wrapper around it. All had at least one of the six mistakes below. Eight had three or more.
Mistake 1 — The Hardcoded Static Secret
Frequency: 10 of 12 repos
// The most common pattern I found
const jwt = require("jsonwebtoken");
const SECRET = "mysecret123";
function generateToken(userId) {
return jwt.sign({ userId }, SECRET, { expiresIn: "1d" });
}
function verifyToken(token) {
return jwt.verify(token, SECRET);
}
Variations I encountered: "secret", "jwt_secret", "your-secret-key", "supersecret", "changeme", and my personal favourite, "TODO_REPLACE_THIS" — which had not been replaced in any of the commits going back two years.
Why it's dangerous:
The security of HMAC-based JWT (HS256, HS384, HS512) depends entirely on the unpredictability of the secret. If an attacker knows or guesses the secret, they can forge any token — including { "role": "admin" } tokens for users that don't exist.
A string like "mysecret123" has roughly 65 bits of entropy in theory, but in practice it's a dictionary target. Security researchers maintain public lists of common JWT secrets scraped from GitHub, Stack Overflow, and tutorial sites. If your secret appears in that list — and "secret", "mysecret", and "jwt_secret" absolutely do — a brute-force attack with a modern GPU takes seconds, not years.
For HS256, you need a minimum of 256 bits (32 bytes) of cryptographically random data. Not a memorable string. Not a UUID. Not your app name.
The fix:
// Generate once using Node's crypto module
const crypto = require("crypto");
console.log(crypto.randomBytes(32).toString("hex"));
// outputs: 3f2a1b9c4d8e7f6a... (64 hex chars = 256 bits)
Store this in an environment variable. Never in source code.
// In production
const SECRET = process.env.JWT_SECRET;
if (!SECRET || SECRET.length < 32) {
throw new Error("JWT_SECRET must be set and at least 32 characters");
}
If you need a production-grade 256-bit secret right now, jwtsecretgenerator.com/tools/jwt-secret-generator generates one client-side using the Web Crypto API — nothing leaves your browser. It's the fastest way to get a properly random secret with zero server-side risk.
Mistake 2 — The algorithm: "none" Attack Surface
Frequency: 7 of 12 repos
The classic JWT algorithm confusion attack. Here's the vulnerable pattern I found repeatedly:
// Vulnerable — no algorithm allowlist
function verifyToken(token) {
try {
return jwt.verify(token, SECRET);
} catch (err) {
return null;
}
}
An attacker who can intercept or modify a token can change the header from:
{ "alg": "HS256", "typ": "JWT" }
to:
{ "alg": "none", "typ": "JWT" }
Then strip the signature entirely. Older versions of some JWT libraries — and jsonwebtoken without explicit algorithm enforcement — will verify this token as valid, because a "none" algorithm means "no signature required." The attacker has just granted themselves any claims they want.
Here's what a decoded alg: none attack payload looks like in practice:
# Header (base64url decoded)
{"alg":"none","typ":"JWT"}
# Payload (base64url decoded)
{"userId":"1","role":"admin","iat":1714000000}
# Signature
(empty string)
# Final token
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOiIxIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzE0MDAwMDAwfQ.
The fix:
Always pass an explicit algorithms allowlist to jwt.verify(). This is not optional.
// Safe — explicit algorithm allowlist
function verifyToken(token) {
try {
return jwt.verify(token, SECRET, {
algorithms: ["HS256"], // NEVER include "none"
});
} catch (err) {
return null;
}
}
If you use RS256 (asymmetric), explicitly allow only ["RS256"]. Never use an empty array or omit the option entirely.
Mistake 3 — Secrets Committed to Git
Frequency: 6 of 12 repos
A few repos had cleaned up their secrets into environment variables — but their Git history told a different story. You can search for this pattern yourself:
# GitHub code search query (do not run on live repos you don't own)
language:JavaScript "jwt.sign" "secret" NOT ".env"
Results: tens of thousands of public files. Many are tutorials. Many are not.
Here's the dangerous reality of Git history: even after you delete a secret from your codebase, it still exists in every commit before the deletion. Anyone who cloned the repo before you pushed the fix still has it. Anyone with access to GitHub's search can find it.
I found this specific pattern in a repo with 3,000+ stars — a .env.example file that had been copy-pasted directly into .env and committed:
# .env (committed by mistake, later deleted — but still in git log)
DATABASE_URL=mongodb://localhost/myapp
JWT_SECRET=f7k2j9x1_my_production_secret_do_not_share
STRIPE_SECRET_KEY=sk_live_...
The sk_live_ key had already been rotated. The JWT secret had not.
The fix:
Add .env to .gitignore before your first commit. Always.
# .gitignore
.env
.env.local
.env.production
*.pem
If a secret is already in your history, rotation is mandatory — not optional.
# Rotate immediately, then use git filter-repo to purge history
pip install git-filter-repo
git filter-repo --path .env --invert-paths
# Force push to all remotes (coordinate with your team first)
git push origin --force --all
Then rotate the secret itself, invalidating all existing tokens.
Mistake 4 — Expiration (exp) Not Enforced
Frequency: 5 of 12 repos
Signature verification and expiry validation are separate concerns. Some repos verify one and skip the other.
// Subtly broken — verifies signature but issues tokens that never expire
function generateToken(userId) {
// No expiresIn option!
return jwt.sign({ userId }, SECRET);
}
// And on the verification side, a custom decoder that only checks signature:
function getUserFromToken(token) {
const decoded = jwt.decode(token); // decode, not verify
if (decoded && decoded.userId) {
return decoded.userId;
}
return null;
}
jwt.decode() does not verify the signature. It does not check expiry. It literally just base64-decodes the payload. If your auth middleware calls jwt.decode() instead of jwt.verify(), your entire auth layer is bypassed.
Even when jwt.verify() is used correctly, I found several repos that issued tokens with no exp claim at all. A token without exp never expires. Ever. If it leaks — in a log file, in a frontend error boundary, in a network request captured at a coffee shop — it's valid forever.
The fix:
// Always set expiresIn
function generateToken(userId) {
return jwt.sign(
{ userId },
SECRET,
{
expiresIn: "15m", // short-lived access tokens
algorithm: "HS256",
}
);
}
// Always use verify(), never decode() for auth
function verifyToken(token) {
return jwt.verify(token, SECRET, {
algorithms: ["HS256"],
// clockTolerance: 30 (optional: allow 30s clock skew)
});
}
For most applications: access tokens at 15 minutes, refresh tokens at 7 days stored in an httpOnly cookie.
Mistake 5 — Signing and Verification With Different Secrets
Frequency: 3 of 12 repos
This one is subtle and disproportionately common in microservice architectures. It surfaces as a maddening "invalid signature" error in production that works perfectly in development.
Here's the pattern from a multi-service repo:
// auth-service/src/token.js
const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
expiresIn: "1d",
});
// api-gateway/src/middleware/auth.js
const decoded = jwt.verify(token, process.env.APP_SECRET); // different env var name!
In docker-compose.yml for local development:
auth-service:
environment:
JWT_SECRET: "dev-secret"
api-gateway:
environment:
APP_SECRET: "dev-secret" # same value locally, easy to miss
In production the values diverged. Someone set APP_SECRET to a new rotated value and didn't update JWT_SECRET. Tokens signed by auth-service became immediately invalid everywhere else.
The fix:
Standardize the environment variable name across all services. Use a shared secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler) so all services draw from a single source of truth.
// Centralized config with validation — same module or same env var name across services
const config = {
jwtSecret: process.env.JWT_SECRET,
jwtAlgorithm: "HS256",
};
// Validate at startup, not at runtime
if (!config.jwtSecret) {
throw new Error("JWT_SECRET is required");
}
module.exports = config;
And document which service signs, which services verify, and what the shared secret name is. Put it in your runbook.
Mistake 6 — Short Human-Readable Secrets With HS256
Frequency: 9 of 12 repos
This is the most insidious mistake because the code looks "fixed." The developer has moved the secret to an environment variable. They're not hardcoding it. They're being responsible. But the secret itself is:
JWT_SECRET=MyApp2024Secure!
Twelve characters. Entirely alphabetic with one symbol and a number. Memorable enough to type by hand.
Here's the entropy problem: HS256 operates on raw bytes. The question is not how long the string looks — it's how many possible values it could be.
Character set for typical "memorable" passwords:
- Lowercase letters: 26
- Uppercase letters: 26
- Digits: 10
- Common symbols: 32
Total: ~94 characters
Entropy per character: log2(94) ≈ 6.5 bits
A 12-character password: 12 × 6.5 = ~78 bits of entropy
HS256 security requirement: 256 bits minimum
Shortfall: 178 bits
78 bits sounds like a lot until you realize that modern GPU clusters can crack bcrypt hashes at millions of attempts per second. JWT secrets are not bcrypt — they're HMAC-SHA256, which is orders of magnitude faster to brute force. Security researchers maintain curated dictionaries of common JWT secrets — "MyApp2024", company names, and any string that appears in public repos are all in there.
If your secret is human-generated, it's a target. Full stop.
The fix:
Use a CSPRNG (Cryptographically Secure Pseudorandom Number Generator) to generate 256+ bits of entropy. The result will not be memorable. That's the point.
// Node.js — run this once, save the output in your secrets manager
const crypto = require("crypto");
// 32 bytes = 256 bits
const secret = crypto.randomBytes(32).toString("base64url");
console.log(secret);
// Example output: 3Hs8dKj2mNpQrT5wXvYzAb7eGcFhIiJl (never use this specific value)
# Or from the shell
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
# Or with openssl
openssl rand -base64 32
The result looks like noise. It should. Store it in your secrets manager, inject it at runtime, rotate it when team members leave or secrets are suspected of compromise.
The Pre-Ship JWT Checklist
Before you deploy JWT-based auth, run through every item:
Secret quality
- [ ] Secret is generated by a CSPRNG (not typed by a human)
- [ ] Secret is at least 32 bytes (256 bits) for HS256
- [ ] Secret is stored in a secrets manager or environment variable — never in code Code hygiene
- [ ]
.envis in.gitignoreand was never committed - [ ]
git logdoes not contain the secret in any historical commit - [ ]
jwt.verify()is used everywhere (neverjwt.decode()for auth) Algorithm enforcement - [ ]
algorithmsoption is explicitly set injwt.verify() - [ ]
"none"is not in the allowed algorithms list - [ ] If using RS256/ES256, private key never leaves the signing service Token lifecycle
- [ ] All tokens have an
expclaim (expiresInoption is set) - [ ] Access token expiry is 15–60 minutes maximum
- [ ] Refresh tokens are stored in httpOnly cookies, not localStorage Multi-service safety
- [ ] All services use the same environment variable name for the JWT secret
- [ ] Secret is sourced from a shared secrets store, not per-service
.envfiles - [ ] Startup validation throws if
JWT_SECRETis missing or too short
Closing Thought
None of these mistakes are exotic. They're copy-paste errors, tutorial leftovers, and configuration drift between environments. The reason they appear in repos with thousands of stars is the same reason they appear everywhere: JWT's happy path is completely invisible to security issues. The token works. Auth passes. Tests pass. Nothing breaks until someone exploits it.
The most important item on the checklist is the first one. A CSPRNG-generated secret neutralizes most of the other risks at source — you can't brute force 256 bits of true randomness, even with excellent tooling. Get that right, enforce algorithm allowlists, and set expiry. The rest is defense in depth.
If you want to generate a properly random secret right now without writing a script, jwtsecretgenerator.com does it entirely client-side with the Web Crypto API. No accounts, no logs, no network request for the key itself. Just a cryptographically sound secret ready to paste into your secrets manager.
Found a mistake I missed? A 7th pattern that deserves to be on this list? Drop it in the comments — I'll update the post and credit you.
Top comments (0)