About me
Blog
Europe/Berlin
--:--:--

Why Your Backend JWT Setup Is Insecure Despite Using a Library

March 17, 2026Your JWT library is correctly implemented. Yet your setup is insecure. I know -- that sounds provocative. You installed jsonwebtoken or PyJWT, read the docs, tokens are being generated and validated. Tests are green. Ship it. But here's the thing: the library itself is rarely the problem. The problem is what you don't configure around it. And that's exactly what I keep seeing -- both when I develop backends and when I take them apart as a pentester. In this article, I'll show you the five most common JWT mistakes I find in audits. For each mistake, you'll first get the attacker's perspective (how I exploit it) and then the fix (how to do it right). Because that's precisely the point: if you only wear the developer hat, you see a functioning token. If you put on the attacker hat, you see an attack surface.
The JWT standard defines a header that specifies the algorithm used. A normal token looks like this:
Json
{
  "alg": "HS256",
  "typ": "JWT"
}
The alg:none attack is brutally simple: the attacker changes the header to "alg": "none", removes the signature -- and if your backend doesn't explicitly enforce the algorithm, it accepts the token just like that. Without any verification. Here's how an attacker builds it in Python:
Python
import base64
import json

# Header: set algorithm to "none"
header = base64.urlsafe_b64encode(
    json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b"=")

# Payload: grant ourselves admin rights
payload = base64.urlsafe_b64encode(
    json.dumps({"sub": "1", "role": "admin", "exp": 9999999999}).encode()
).rstrip(b"=")

# Signature: just leave it empty
forged_token = f"{header.decode()}.{payload.decode()}."
print(forged_token)
That's it. No secret needed, no cryptography. If your backend accepts this token, the attacker has admin access. Always enforce the algorithm during validation explicitly. Never rely on the alg value in the token itself -- it comes from the client and is therefore attacker-controlled. Node.js (jsonwebtoken):
Javascript
const jwt = require('jsonwebtoken');

// WRONG: Algorithm is read from the token
const decoded = jwt.verify(token, secret);

// RIGHT: Algorithm is enforced server-side
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256']  // ONLY accept this algorithm
});
Python (PyJWT):
Python
import jwt

# WRONG
decoded = jwt.decode(token, secret, algorithms=["HS256", "none"])

# RIGHT
decoded = jwt.decode(token, secret, algorithms=["HS256"])
Current versions of PyJWT and jsonwebtoken reject alg:none by default. But: older versions don't, and some developers deliberately disable the check for "development purposes" and forget to re-enable it. I've seen this in production systems. More than once.
If you use HS256 with a weak secret -- say "secret", "password123", or the company name -- then an attacker can crack the secret via brute force. Tools like hashcat or jwt-cracker do this automatically:
Bash
# hashcat in JWT mode
hashcat -a 0 -m 16500 jwt_token.txt wordlist.txt

# Or with the specialized tool
jwt-cracker -t eyJhbGciOiJIUzI1NiIs... -d wordlist.txt
Once the secret is known, the attacker can create arbitrary valid tokens. Game over. But it gets worse: there's the so-called Key Confusion Attack. If your backend accepts both HS256 (symmetric) and RS256 (asymmetric), an attacker can use the public RSA key (which is public by design) as the HS256 secret. The library verifies the token using the public key as the HMAC secret -- and it passes. Option A: Strong Secret with HS256
Javascript
const crypto = require('crypto');

// Generate a cryptographically secure secret
// At least 256 bits (32 bytes) for HS256
const secret = crypto.randomBytes(64).toString('hex');
// Result: "a3f8b2c1d4e5..." (128 hex characters)
Option B (recommended): Asymmetric Keys (RS256/ES256)
Javascript
const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('./keys/private.pem');
const publicKey = fs.readFileSync('./keys/public.pem');

// Sign with private key
const token = jwt.sign({ sub: userId, role: 'user' }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m'
});

// Verify with public key
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // Enforce algorithm!
});
The advantage of asymmetric keys: the private key never leaves the auth server. All other services only need the public key for verification -- and even if it's compromised, nobody can create new tokens with it.
I regularly see JWTs with an exp claim of 30 days, 90 days -- or none at all. This means: if an attacker intercepts a token once (XSS, log leak, man-in-the-middle, or simply from the browser storage of an unlocked laptop), they have access for weeks or months.
Python
import jwt
import base64
import json

# Token extracted from the target application's LocalStorage
stolen_token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc3NTk5NjAwfQ.xxxxx"

# When does it expire?
payload = json.loads(
    base64.urlsafe_b64decode(stolen_token.split('.')[1] + '==')
)
from datetime import datetime
print(f"Expires: {datetime.fromtimestamp(payload['exp'])}")
# Output: Expires: 2026-06-28 12:00:00
# ...three months. Wonderful (for the attacker).
Without expiration, the token is valid forever -- there's no built-in mechanism to invalidate it because JWTs are stateless.
Javascript
const jwt = require('jsonwebtoken');

// Access token: short lifetime (15 minutes)
const accessToken = jwt.sign(
  { sub: userId, role: userRole },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m'    // 15 minutes, not 15 days
  }
);

// Refresh token: longer lifetime, but stored in the DB
// and therefore revocable
const refreshToken = jwt.sign(
  { sub: userId, type: 'refresh' },
  refreshPrivateKey,
  {
    algorithm: 'RS256',
    expiresIn: '7d'
  }
);
The golden rule: Access tokens short-lived (5-15 minutes), refresh tokens longer but revocable. If you can't invalidate a token, it must be short-lived enough that the damage remains limited. Bonus tip: Always verify the exp claim during validation. Most libraries do this by default, but make sure you don't accidentally disable it:
Python
import jwt

# WRONG: Expiration check disabled
decoded = jwt.decode(token, secret, algorithms=["HS256"],
                     options={"verify_exp": False})  # NEVER in production

# RIGHT: Keep default behavior
decoded = jwt.decode(token, secret, algorithms=["HS256"])
# Raises jwt.ExpiredSignatureError if expired

Imagine you run two services: an internal admin service and a public API. Both use JWTs, both use the same signing key (this happens more often than you'd think -- especially in microservice setups with a shared secret). A regular user has a valid token for the public API. If the admin service checks neither iss (issuer) nor aud (audience), it accepts the API token just the same -- and the user suddenly has admin access.
Python
# Token for the public API (completely legitimate)
api_token_payload = {
    "sub": "user-123",
    "role": "user",
    "iss": "public-api",
    "aud": "public-api",
    "exp": 1742400000
}

# The admin service doesn't check iss/aud?
# Then it accepts this token too. Privilege escalation.
This is not a theoretical scenario. In pentests, I regularly find this in microservice architectures where someone used a shared secret "for simplicity's sake".
Javascript
// When creating: set issuer and audience
const token = jwt.sign(
  { sub: userId, role: 'admin' },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'admin-service',
    audience: 'admin-dashboard'
  }
);

// When validating: enforce issuer and audience
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'admin-service',
  audience: 'admin-dashboard'
});
// Tokens from "public-api" are now rejected
Python
# Python equivalent
decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    audience="admin-dashboard",
    issuer="admin-service"
)
Every service should only accept tokens that were explicitly issued for it. Sounds obvious -- but it's often forgotten.
This is the mistake that makes me rub my hands as a pentester. Developers stuff sensitive data into the JWT payload thinking it's "encrypted" because the token looks like an unreadable string. Spoiler: JWT payloads are Base64-encoded, not encrypted. Anyone can read them. No key, no tools, just like that.
Python
import base64
import json

token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZW1haWwiOiJhZG1pbkBjb21wYW55LmNvbSIsInBhc3N3b3JkX2hhc2giOiIkMmIkMTIkYWJjZGVmIiwiaW50ZXJuYWxfaXAiOiIxMC4wLjEuNTAiLCJkYl9yb2xlIjoic3VwZXJ1c2VyIn0.xxxxx"

# Decode payload -- no key needed!
payload_b64 = token.split('.')[1]
# Add Base64 padding
payload_b64 += '=' * (4 - len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))

print(json.dumps(payload, indent=2))
Output:
Json
{
  "sub": "1234",
  "email": "admin@company.com",
  "password_hash": "$2b$12$abcdef",
  "internal_ip": "10.0.1.50",
  "db_role": "superuser"
}
Email, password hash, internal IP, database role -- all in plain text. I don't even need to steal the token; if it's transmitted over an insecure connection or shows up in a log, I have a complete view into the internal infrastructure. You can test this yourself: go to jwt.io, paste any JWT, and you'll see the payload immediately. No secret needed for reading -- only for verifying the signature. Only put the bare minimum in the JWT payload:
Javascript
// WRONG: Sensitive data in the token
const badToken = jwt.sign({
  sub: userId,
  email: user.email,
  passwordHash: user.passwordHash,    // Never!
  internalIp: '10.0.1.50',           // Never!
  creditCard: user.creditCardLast4,   // Never!
  dbRole: 'superuser'                 // Never!
}, privateKey, { algorithm: 'RS256' });

// RIGHT: Only IDs and roles, load everything else from the DB
const goodToken = jwt.sign({
  sub: userId,
  role: 'admin',
  jti: crypto.randomUUID()  // Unique token ID for revocation
}, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m',
  issuer: 'auth-service',
  audience: 'api'
});
The rule of thumb: treat the JWT payload like a public bulletin board. Everything in it can be read by anyone. If you need encrypted tokens, look into JWE (JSON Web Encryption) -- but in most cases, the better solution is simply not putting sensitive data in the token.
JWTs are not the answer to everything. Here are situations where classic session-based authentication is the better choice:
  • You need immediate invalidation. When a user changes their password or an admin locks an account, access must end immediately. JWTs are stateless -- you can't revoke an issued token without maintaining a blacklist. And a blacklist is basically... a session database.
  • Your application is a classic monolith. If you have a single server, JWT offers no advantage over server-side sessions. Sessions are simpler, more secure (no token theft from browser storage), and you get invalidation for free.
  • You store JWTs in LocalStorage. LocalStorage is accessible to every JavaScript code on the page. A single XSS vulnerability -- and the attacker has the token. HttpOnly cookies are the safer choice for browser-based applications.
Javascript
// If you must use JWTs in the browser:
// HttpOnly cookie instead of LocalStorage
res.cookie('access_token', token, {
  httpOnly: true,    // No access via JavaScript
  secure: true,      // Only over HTTPS
  sameSite: 'strict', // CSRF protection
  maxAge: 15 * 60 * 1000  // 15 minutes
});
JWTs shine in microservice architectures, in service-to-service communication, and in systems where multiple services need to verify the authenticity of a token without contacting the auth server. For everything else, sessions are often the simpler and more secure choice.
Before you push your next JWT setup to production, go through this list:
  • [ ] Algorithm is enforced server-side (algorithms: ['RS256'])
  • [ ] alg: none is explicitly rejected
  • [ ] Secret is cryptographically random and at least 256 bits long (or asymmetric keys)
  • [ ] Access tokens have a short lifetime (5-15 minutes)
  • [ ] Refresh tokens are stored in the database and revocable
  • [ ] iss and aud claims are set and validated
  • [ ] No sensitive data in the payload (no emails, hashes, internal IPs)
  • [ ] Tokens are stored in HttpOnly cookies, not in LocalStorage
  • [ ] HTTPS is mandatory (no tokens over unencrypted connections)
  • [ ] There is a plan for key rotation

A developer sees the token. A pentester sees the attack surface. A full-cycle security engineer sees both. Most JWT vulnerabilities I find in audits are not library bugs. They are configuration errors and false assumptions. The library does exactly what you tell it -- and if you don't tell it to enforce the algorithm, check the expiration, and validate the issuer, then it simply won't. The good news: all five mistakes in this article can be fixed in minutes. The bad news: if you don't fix them, someone else will find them. And they might have less good intentions than I do.
Want to know if your JWT setup -- or your backend in general -- can withstand a real attack? I combine backend development with offensive security and test your systems from both perspectives. Contact me for a security review or workshop: kontakt@buengener-software.de