JWT Security: Common Mistakes to Avoid
JSON Web Tokens (JWT) have become the de‑facto way to convey authentication and authorization data across modern web apps. Their compact, URL‑safe format makes them perfect for APIs, mobile clients, and single‑page applications. However, a misstep in how you generate, store, or validate a JWT can turn a powerful tool into a serious security liability.
1. Storing JWTs in Insecure Browser Storage
Developers often reach for localStorage or sessionStorage because they’re easy to access from JavaScript. Unfortunately, these storages are fully exposed to any script that runs on the page, including malicious third‑party code injected via XSS. Once an attacker can read the token, they can impersonate the user until the token expires.
Safer alternatives
- Store the JWT in an HttpOnly cookie. The browser will automatically send it with each request, and JavaScript cannot read it.
- Use the SameSite attribute to mitigate CSRF attacks.
- If you must keep the token client‑side, consider encrypting it with a short‑lived, per‑session key.
Pro tip: Pair HttpOnly cookies withSecureandSameSite=Strictto lock the token down to HTTPS traffic and same‑origin requests.
2. Skipping Signature Verification
It’s tempting to trust the payload because it’s base64‑encoded and looks readable. The reality is that anyone can decode a JWT; the only thing that guarantees integrity is the cryptographic signature. Failing to verify that signature means an attacker can craft a token with arbitrary claims and gain unauthorized access.
Correct verification with PyJWT
import jwt
from jwt import InvalidTokenError
SECRET = "super-secret-key"
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(
token,
SECRET,
algorithms=["HS256"],
audience="my-api",
issuer="auth.myapp.com"
)
return payload
except InvalidTokenError as e:
raise ValueError(f"Invalid JWT: {e}")
This snippet validates the signature, checks the audience and issuer, and raises an exception for any tampering. Never call jwt.decode(..., verify=False) in production.
3. Using Weak or Deprecated Algorithms
The JWT spec supports several algorithms, but not all are created equal. none disables signing entirely, and algorithms like HS256 rely on a shared secret, which can be problematic in a micro‑services environment. Asymmetric algorithms (RS256, ES256) provide better separation of concerns.
Switching to RS256
import jwt
with open("private_key.pem", "rb") as f:
private_key = f.read()
with open("public_key.pem", "rb") as f:
public_key = f.read()
def create_token(user_id: str) -> str:
payload = {
"sub": user_id,
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
"aud": "my-api",
"iss": "auth.myapp.com"
}
token = jwt.encode(payload, private_key, algorithm="RS256")
return token
def verify_token(token: str) -> dict:
return jwt.decode(token, public_key, algorithms=["RS256"], audience="my-api", issuer="auth.myapp.com")
By using a private key to sign and a public key to verify, you eliminate the risk of secret leakage across services.
Pro tip: Rotate your RSA key pair at least once a year, and keep the private key offline whenever possible.
4. Forgetting Token Expiration (exp) Claims
A JWT without an exp claim lives forever, giving attackers unlimited time to abuse a stolen token. Even short‑lived tokens should have a reasonable expiration window—typically 5 to 15 minutes for access tokens and up to 24 hours for refresh tokens.
Setting expiration correctly
import time
import jwt
def generate_access_token(user_id: str) -> str:
now = int(time.time())
payload = {
"sub": user_id,
"iat": now,
"exp": now + 900, # 15 minutes
"aud": "my-api",
"iss": "auth.myapp.com"
}
return jwt.encode(payload, SECRET, algorithm="HS256")
Always calculate exp relative to iat to avoid clock‑drift issues.
5. Ignoring Token Revocation Strategies
JWTs are stateless by design, which means the server doesn’t keep a record of issued tokens. While this boosts performance, it also makes revocation tricky. If a user logs out or a token is compromised, you need a way to invalidate it before it naturally expires.
Common revocation patterns
- Blacklist store: Keep a Redis set of revoked token IDs (
jti) and check it on every request. - Short‑lived access tokens + refresh tokens: Revoke the refresh token; the short access token will expire quickly.
- Versioned claims: Include a
token_versionin the payload and bump the version on password change.
6. Overloading JWTs with Sensitive Data
Because the payload is merely base64‑encoded, anyone who intercepts the token can read its contents. Storing passwords, credit‑card numbers, or personal identifiers inside a JWT is a recipe for data leakage.
What belongs in a JWT?
- User identifier (
sub) - Roles or scopes needed for authorization
- Token issuance and expiration timestamps
- Non‑sensitive metadata (e.g., locale)
If you need to transmit confidential information, encrypt it separately or use a secure session store.
7. Reusing the Same Secret Across Multiple Services
When several micro‑services share a single symmetric secret, a breach in any one service compromises the entire ecosystem. An attacker who extracts the secret from a low‑risk service can forge tokens for high‑value endpoints.
Best practice: per‑service keys
- Generate a unique secret for each service or domain.
- Store secrets in a dedicated vault (e.g., HashiCorp Vault, AWS Secrets Manager).
- Implement automated key rotation to limit exposure time.
Pro tip: Use environment‑specific prefixes (e.g.,DEV_AUTH_SECRET,PROD_AUTH_SECRET) to avoid accidental cross‑environment leakage.
8. Not Rotating Keys Regularly
Static keys are attractive targets for attackers because they never change. Even if a key remains undiscovered today, the longer it lives the higher the chance of exposure through logs, backups, or insider threats.
Key rotation workflow
- Generate a new key pair (or secret) and store it alongside the old one.
- Update your token‑issuing service to sign with the new key while still accepting the old key for verification.
- Gradually phase out the old key after all existing tokens have expired.
- Delete the old key from storage.
Automation tools like AWS KMS can handle rotation transparently, reducing operational overhead.
9. Serving JWTs Over Unencrypted HTTP
Transporting tokens over plain HTTP exposes them to man‑in‑the‑middle (MITM) attacks. An attacker can sniff the token, replay it, and gain full access to the user’s account.
Enforce HTTPS everywhere
- Redirect all HTTP requests to HTTPS at the load balancer level.
- Set the
Secureflag on cookies to block transmission over insecure channels. - Enable HSTS (HTTP Strict Transport Security) to force browsers to use HTTPS.
Remember: even internal service‑to‑service calls should be encrypted, especially when they carry JWTs.
10. Poor Error Handling and Information Leakage
When a token validation fails, returning detailed error messages (e.g., “Signature mismatch” or “Token expired at …”) gives attackers clues about your implementation. Overly verbose responses can accelerate reverse‑engineering attempts.
Secure error responses
def safe_verify(token: str) -> dict | None:
try:
return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
except jwt.ExpiredSignatureError:
# Log the specific reason internally
logger.warning("JWT expired")
except jwt.InvalidTokenError:
logger.warning("Invalid JWT")
# Return a generic response to the client
return None
Log the detailed reason on the server, but send a generic “Invalid token” message to the client.
Pro tip: Centralize JWT error handling in middleware so every endpoint gets consistent protection.
Real‑World Use Cases & Lessons Learned
Case 1: E‑commerce platform compromised by XSS. The team stored JWTs in localStorage. A malicious script injected via a third‑party widget read the token and performed unauthorized purchases. The fix was to move the token into an HttpOnly, SameSite cookie and implement CSP headers.
Case 2: SaaS API with shared secret. A low‑privilege analytics service leaked its configuration file, exposing the secret used by the core billing API. Attackers forged billing tokens and generated fraudulent invoices. After the breach, the company switched to RS256 with per‑service key pairs and introduced automated key rotation.
Case 3: Mobile app using long‑lived JWTs. The app cached a token with a 30‑day expiration. When the user’s device was stolen, the attacker used the token until it naturally expired. The solution was to issue short‑lived access tokens (5 min) and a refresh token that required biometric verification on each refresh.
Checklist for Secure JWT Implementation
- Store tokens in HttpOnly, Secure, SameSite cookies.
- Always verify the signature and validate
audandissclaims. - Prefer asymmetric algorithms (RS256, ES256) over symmetric ones.
- Include a short
expclaim (5‑15 min for access tokens). - Implement a revocation strategy (blacklist, short‑lived tokens, versioning).
- Never place sensitive data in the payload.
- Use unique secrets/keys per service and store them in a vault.
- Rotate keys regularly and automate the process.
- Serve all traffic over HTTPS with HSTS enabled.
- Return generic error messages to clients; log details securely.
Conclusion
JWTs are powerful, but they are only as secure as the practices that surround them. By avoiding the common pitfalls outlined above—especially insecure storage, lax verification, weak algorithms, and missing expiration—you dramatically reduce the attack surface of your applications. Pair these technical safeguards with robust operational policies like key rotation, secret management, and HTTPS enforcement, and you’ll have a JWT strategy that scales safely across users, services, and environments.