DEV Community

Cover image for I Audited 12 Open Source Projects' JWT Implementations and Found the Same 6 Mistakes in All of Them
SHAHJAHAN MD. SWAJAN
SHAHJAHAN MD. SWAJAN

Posted on

I Audited 12 Open Source Projects' JWT Implementations and Found the Same 6 Mistakes in All of Them

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",
};
Enter fullscreen mode Exit fullscreen mode

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 jsonwebtoken or 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);
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

An attacker who can intercept or modify a token can change the header from:

{ "alg": "HS256", "typ": "JWT" }
Enter fullscreen mode Exit fullscreen mode

to:

{ "alg": "none", "typ": "JWT" }
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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_...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
  });
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
# Or from the shell
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

# Or with openssl
openssl rand -base64 32
Enter fullscreen mode Exit fullscreen mode

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
  • [ ] .env is in .gitignore and was never committed
  • [ ] git log does not contain the secret in any historical commit
  • [ ] jwt.verify() is used everywhere (never jwt.decode() for auth) Algorithm enforcement
  • [ ] algorithms option is explicitly set in jwt.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 exp claim (expiresIn option 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 .env files
  • [ ] Startup validation throws if JWT_SECRET is 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)