If your app signs JWTs with HS256, there's a check worth doing today. It takes a minute, and the failure mode is bad.
Why a weak HMAC secret is worse than it sounds
An HS256 signature is just HMAC-SHA256(secret, header + "." + payload).
The header and payload are not secret. They're only base64url-encoded, so anyone holding one of your tokens has the exact message that was signed and the resulting signature. The only unknown is the secret.
That means someone can guess secrets offline: take a candidate, compute the HMAC, compare it to the signature. No login endpoint, no rate limit, no lockout, no logs on your side. Tools like hashcat and jwt_tool already do this at scale.
If they guess right, they can mint their own tokens: any sub, any role, any exp. Your server will trust all of them.
How weak secrets end up in production
Nobody picks secret on purpose. It gets there through:
- a tutorial that used
jwt.sign(payload, "secret")and was never revisited -
.env.examplewithJWT_SECRET=changemecopied to.envand shipped - a placeholder from local dev that quietly became the prod value
- a short "memorable" secret, because someone had to type it into a dashboard
Checking a token yourself
Here's the whole check in a few lines of browser or Node 20+ JavaScript:
async function trySecret(token, guess) {
const [h, p, sig] = token.split(".");
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", enc.encode(guess),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${h}.${p}`));
const b64url = btoa(String.fromCharCode(...new Uint8Array(mac)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return b64url === sig;
}
for (const guess of ["secret", "password", "changeme", "jwt_secret", "your-256-bit-secret"]) {
if (await trySecret(myToken, guess)) console.log("Weak secret:", guess);
}
One gotcha I hit while building this: sign the original header.payload substring exactly as it appears in the token. If you decode the JSON and re-serialize it, key order or whitespace can change, the HMAC won't match, and the check will tell you a weak secret is safe.
While you're in there, check these too
-
alg: none: a token claiming no signature should never be accepted. Make sure your library is pinned to the algorithms you expect. -
No
exp: a token that never expires is valid forever once it leaks. - Personal data in the payload: base64 is not encryption. Emails, names, and internal IDs in the payload are readable by anyone holding the token.
The fix
Use a long random secret, not a word:
openssl rand -base64 32
# or
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Then rotate it, which invalidates every existing token. If several services need to verify tokens, consider RS256 or ES256 instead, so verifiers only ever hold a public key.
A tool for this
I got tired of writing that loop, so I built the check into a JWT debugger: devbelt.dev/jwt-debugger. It tests the token against a small built-in list of common weak secrets in a Web Worker, flags alg: none, missing exp, and likely personal data, and runs entirely in your browser. Nothing is sent anywhere, and nothing goes into the URL.
To be clear about what it is: a self-test for tokens you own, with a deliberately tiny wordlist. It's not a cracking tool. If you'd rather not paste a production token into any website, mine included, the snippet above does the same core check locally.
It also explains why verification fails (wrong aud, wrong iss, clock skew) instead of only saying "invalid signature", which is the part I use most.
What's the worst JWT setup you've run into in the wild? I'm curious which mistakes are most common.
Top comments (0)