Why Weak JWT Secrets Fail Catastrophically
HMAC signatures protect JWT integrity: a server-issued token cannot be modified by a client because any change invalidates the signature — unlessthe client can guess the signing secret and produce a valid signature themselves. If the secret has low entropy, an attacker who captures a single signed JWT can run offline brute-force. There is no rate-limit, no network round-trip, no lockout. A 2017 Auth0 study found over 100,000 production JWT deployments using "secret", "jwtsecret", or the default example from a tutorial — all broken in under one second on a laptop. The failure mode is total: once the secret leaks, every token your service has ever issued or will issue is forgeable.
Key Length Requirements by Algorithm
- HS256:
- 256 bits = 32 bytes minimum (RFC 7518 §3.2)
- HS384:
- 384 bits = 48 bytes minimum
- HS512:
- 512 bits = 64 bytes minimum
- RS256 / ES256:
- Not a secret — RSA / ECDSA key pair instead (
openssl genpkey)
Longer-than-minimum keys do not increase cryptographic strength beyond the hash output size, but most libraries accept any length and some policy scanners prefer round numbers (64 bytes for HS256 is fine).
Generate on the Terminal
# openssl — available on nearly every Unix system
openssl rand -base64 32 # HS256 (44-char Base64 output)
openssl rand -base64 48 # HS384
openssl rand -base64 64 # HS512
openssl rand -hex 32 # Hex output (64 chars)
# /dev/urandom (any POSIX system without openssl)
head -c 32 /dev/urandom | base64
# macOS without openssl
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64
# PowerShell (Windows)
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
# Better — uses crypto RNG:
$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)Generate in Code
// Node.js — crypto module is built-in
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('base64');
console.log(secret);
// Browser — Web Crypto API
const buf = new Uint8Array(32);
crypto.getRandomValues(buf);
const secret = btoa(String.fromCharCode(...buf));
# Python 3.6+
import secrets
secret = secrets.token_urlsafe(32) # URL-safe Base64, 43 chars
# or
secret = secrets.token_hex(32) # 64-char hex
# Go
import (
"crypto/rand"
"encoding/base64"
)
b := make([]byte, 32)
rand.Read(b)
secret := base64.StdEncoding.EncodeToString(b)
# Rust
use rand::RngCore;
use base64::Engine;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
let secret = base64::engine::general_purpose::STANDARD.encode(key);
# Ruby
require 'securerandom'
secret = SecureRandom.base64(32)
# Java 9+
byte[] bytes = new byte[32];
SecureRandom.getInstanceStrong().nextBytes(bytes);
String secret = Base64.getEncoder().encodeToString(bytes);What NOT to Use
- Math.random() in JavaScript. Not cryptographic. Predictable from a handful of outputs.
- UUIDs. Only ~122 bits of entropy and the format reveals the version — reduce to raw bytes before use, or just generate 32 random bytes directly.
- Timestamps + hashes. "sha256(Date.now())" has only ~30 bits of practical entropy; an attacker guesses the timestamp.
- Password-derived keys without PBKDF2 / Argon2. If you must derive a key from a password, run it through 100,000+ iterations of PBKDF2-HMAC-SHA256 first — but a true random secret is always better.
- The example from the library docs. Every scraper on the internet has indexed those strings.
Store It Correctly
- Secrets manager: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Doppler, 1Password Service Accounts.
- Environment variable: inject via the deploy pipeline into
JWT_SECRET. Read asprocess.env.JWT_SECRET. - Never commit: add
.env,secrets.yaml, and.pemto.gitignore. Rungit secrets --installorgitleaksas a pre-commit hook. - Audit periodically:
trufflehogorgitleakson your repo history catches historical leaks even after.gitignoreis fixed. - Separate per environment: dev, staging, and production MUST have different secrets. A production token should never verify against a development secret.
Rotation Procedure
A zero-downtime rotation keeps verification working while the token population drains:
- Generate a new secret (
JWT_SECRET_NEW) and deploy it alongside the current one (JWT_SECRET). - Update signing code to use
JWT_SECRET_NEWimmediately — new tokens are signed with the new secret. - Update verification code to try
JWT_SECRET_NEWfirst, then fall back toJWT_SECRET. - Wait for the longest token TTL to elapse (e.g. 24 h if tokens live 24 h) — plus a buffer for clock skew.
- Remove the fallback. Promote
JWT_SECRET_NEW→JWT_SECRET. Delete the old secret from the manager.
Related JWT Tools
- JWT Decoder Online — inspect any JWT
- Verify JWT Signature — use your secret to verify a token
- JWT HS256 Decoder — the matching algorithm
- JWT RS256 Decoder — asymmetric alternative (public/private key)
- JWT Generator Online — issue a signed token using your secret
- JWT Debugger — full decode + verify workflow