Skip to content
Home » All Posts » Top 7 JWT Security Mistakes in Python APIs (and How to Fix Them)

Top 7 JWT Security Mistakes in Python APIs (and How to Fix Them)

Introduction: Why JWT Security Mistakes in Python Still Hurt APIs

JSON Web Tokens (JWTs) are my default choice for stateless authentication in Python APIs, especially with frameworks like FastAPI, Flask, and Django REST Framework. They’re compact, easy to pass around, and simple to verify. That convenience is exactly why JWT security mistakes in Python are still so common—and why they hurt production systems more than many teams expect.

In my own projects and code reviews, I keep seeing the same patterns: weak secrets, misconfigured algorithms, tokens that never expire, and homegrown validation logic that quietly skips important checks. None of these look catastrophic during development, but in production they open doors for token forgery, session hijacking, and privilege escalation.

In this guide, I’ll walk through seven specific JWT security mistakes in Python APIs that I’ve seen repeatedly, explain why each one is dangerous, and show concise, practical fixes with Python-focused examples. The goal is simple: help you keep the benefits of JWTs—performance and simplicity—without leaving obvious gaps an attacker can walk through.

1. Using Weak or Hardcoded Secrets for Signing JWTs

The most painful JWT security mistakes in Python APIs often start with a single bad line: a short, guessable, or hardcoded secret for HS256 signing. When I first wired JWTs into a small Flask service, I dropped “mysecret” straight into the code and moved on. It worked fine—until I realised that anyone who can guess or leak that string can forge valid tokens and impersonate any user.

Weak secrets (short strings, dictionary words, or reused passwords) make offline brute-force attacks realistic, especially if an attacker captures a single token. Hardcoding those secrets in the repository means every clone, log, and misconfigured CI job becomes a potential leak. I’ve seen teams rotate database passwords while leaving the JWT secret untouched for years—which quietly undermines the whole authentication layer.

Generate Strong Secrets and Keep Them Out of the Code

What’s worked well for me is treating the JWT signing key like any other high-value credential: long, random, and managed via environment variables or a secret manager. Here’s a simple Python example using PyJWT and an environment variable instead of a hardcoded string:

import os
import secrets
import jwt

# One-time secret generation (run locally and store the value securely)
print(secrets.token_urlsafe(64))  # > use this as your JWT_SECRET_KEY

JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
if not JWT_SECRET_KEY:
    raise RuntimeError("JWT_SECRET_KEY is not set")

payload = {"sub": "123", "role": "user"}

# Always specify the algorithm explicitly
encoded = jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256")
print(encoded)

In my deployments, I prefer to load JWT_SECRET_KEY from environment variables, Docker secrets, or a cloud secret manager rather than config files. This way, rotation is a configuration change, not a code change, and the repository never contains the actual key material.

Future sections will dig into other JWT security mistakes in Python, but fixing this one early—strong, well-managed secrets—dramatically reduces the blast radius of any other misconfiguration. Secrets Management – OWASP Cheat Sheet Series

1. Using Weak or Hardcoded Secrets for Signing JWTs - image 1

2. Not Verifying JWT Signatures Correctly in Python

One of the sneakiest JWT security mistakes in Python is assuming that decoding a token automatically means verifying it. I’ve audited code where JWTs were parsed just to read claims, with signature checks effectively disabled. Everything looked fine in development, but in reality any attacker could craft arbitrary tokens that the API happily trusted.

Never Skip Signature Verification or Trust the alg Header

A classic anti-pattern is calling jwt.decode() without proper options, or worse, with verification turned off. Another is letting the token’s alg header decide the algorithm, opening the door to algorithm-switching attacks.

import os
import jwt
from jwt import InvalidTokenError

JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]

# ❌ Dangerous: no algorithm restriction, or verify=False
# payload = jwt.decode(token, JWT_SECRET_KEY, options={"verify_signature": False})

# ✅ Safer: require verification and pin the allowed algorithms

def decode_access_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            JWT_SECRET_KEY,
            algorithms=["HS256"],   # do NOT trust the alg header
            options={
                "verify_signature": True,
                "verify_exp": True,
            },
        )
    except InvalidTokenError as exc:
        # Log and map to a 401/403 in your framework
        raise ValueError("Invalid access token") from exc

In my own FastAPI projects, I now treat the algorithm list as a hard-coded part of the security policy, not something tokens can negotiate. I also avoid any helper functions or middleware that silently disable verification “just for debugging” — those shortcuts have a bad habit of leaking into production.

Understand PyJWT Verification Options

Another easy mistake is misconfiguring PyJWT’s options so that you think verification is happening when it isn’t. If you set verify_signature to False, or forget to enforce expiration checks, you’re effectively trusting unsigned or expired tokens. What’s worked for me is defining a single, well-reviewed helper like the one above and reusing it across all endpoints, instead of re-implementing decode logic in multiple places.

Before wiring more advanced features, it’s worth revisiting the official docs on PyJWT verification flags and error types to make sure you’re not accidentally relaxing checks you rely on. PyJWT Documentation – Decoding Tokens and Handling Verification

3. Treating JWT as Confidential Instead of Just Integrity-Protected

Another recurring pattern I see in JWT security mistakes in Python is teams assuming that a signed JWT is also encrypted. It isn’t. A normal JWS token is just base64url-encoded JSON with a signature. Anyone who can see the token—browser, proxy, or attacker with access to logs—can decode and read the claims, even if they can’t modify them without breaking the signature.

Early in my own API work, I made the mistake of dropping internal IDs and partial user data into JWTs because it felt “safe enough.” Later, when I base64-decoded a sample token in a terminal, I realised an attacker could have read all of that just as easily.

What’s Actually Visible Inside a Regular JWT

To see how exposed a typical token is, you can reproduce this quick check with Python. This example does not verify the signature on purpose; it only shows how trivial it is to read claims:

import base64
import json

def decode_jwt_payload_without_verification(jwt_token: str) -> dict:
    header_b64, payload_b64, _ = jwt_token.split(".")
    # Add missing padding if necessary
    padding = '=' * (-len(payload_b64) % 4)
    payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding)
    return json.loads(payload_bytes)

sample_token = "<paste_any_jwt_here>"
print(decode_jwt_payload_without_verification(sample_token))

This is exactly what an attacker can do if they intercept tokens over HTTP, read them from logs, or see them in a browser console. If you store addresses, emails, or internal reference IDs in there, they’re not secret anymore.

Keep Sensitive Data Out of Claims (or Use Proper Encryption)

My rule of thumb now is simple: treat signed JWTs as integrity-protected metadata, not as a secret container. I only store what a client could reasonably know anyway—user ID, roles, expiry—and keep sensitive fields behind server-side lookups. If you genuinely need confidentiality, that’s where JWE (encrypted JWTs) or a separate encryption layer comes in, along with strict key management and more complex libraries.

In practice, most Python APIs I’ve worked on stay safer and simpler by keeping tokens minimal and doing extra data fetching on the backend, instead of trying to cram everything into a supposedly “secure” token.

3. Treating JWT as Confidential Instead of Just Integrity-Protected - image 1

4. Ignoring Expiration, Issuer, and Audience Claims

Even when signatures are verified correctly, I still see JWT security mistakes in Python where APIs completely ignore exp, iss, and aud. In one audit I did, a staging token from months ago still worked on production because nothing was checking expiration. That kind of oversight quietly enables replay attacks and cross-service token reuse.

The exp claim limits how long a stolen token is useful. iss tells you who issued it, and aud tells you which service it was meant for. If you skip those checks, any valid token from any environment or service can potentially be replayed against your API, turning your backend into an unintended “deputy” that trusts tokens it should reject.

Enforce Lifetime, Issuer, and Audience with PyJWT

These days, I centralize all my JWT validation in one helper that enforces these claims by default, so individual endpoints don’t forget them:

import os
import time
import jwt
from jwt import InvalidTokenError

JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ISSUER = "https://auth.myapi.com"
JWT_AUDIENCE = "myapi-backend"


def decode_and_validate(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            JWT_SECRET_KEY,
            algorithms=["HS256"],
            options={
                "verify_signature": True,
                "verify_exp": True,
                "verify_iss": True,
                "verify_aud": True,
            },
            issuer=JWT_ISSUER,
            audience=JWT_AUDIENCE,
        )
        return payload
    except InvalidTokenError as exc:
        # Map to HTTP 401/403 in your framework
        raise ValueError("Invalid or expired token") from exc


def create_access_token(sub: str) -> str:
    now = int(time.time())
    payload = {
        "sub": sub,
        "iss": JWT_ISSUER,
        "aud": JWT_AUDIENCE,
        "iat": now,
        "exp": now + 900,  # 15 minutes
    }
    return jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256")

In my experience, short-lived access tokens combined with strict iss and aud checks shut down a whole class of confused-deputy and replay problems without adding much complexity. The key is to make these validations non-optional so they can’t be “temporarily” turned off and forgotten.

5. Storing JWTs Unsafely in Clients and Logs

Even when I lock down signing keys and validation logic, I still see JWT security mistakes in Python that come from outside the backend: the way tokens are stored and exposed. If a JWT leaks through browser storage, URLs, or logs, my carefully written Python verification code doesn’t matter—an attacker can just replay the stolen token.

Avoid URLs, Local Storage, and Verbose Logging

On the client side, I’ve seen tokens stuffed into localStorage, query parameters, and even image URLs. All of those are easy to exfiltrate via XSS or browser history syncing. On the server side, I’ve inherited Python APIs that logged entire Authorization headers for “debugging,” quietly dumping valid tokens into log files, APM tools, and error emails.

These days, my default pattern is:

  • Use secure, HTTP-only cookies for browser-based flows where possible.
  • Never include JWTs in URLs or query strings.
  • Scrub or mask tokens in Python logs and error handlers.
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.middleware("http")
async def auth_logging_sanitizer(request: Request, call_next):
    auth = request.headers.get("authorization", "")
    if auth.lower().startswith("bearer "):
        # Log only the prefix of the token, for correlation
        token_preview = auth.split()[1][:16] + "..."
        request.state.token_preview = token_preview
    response = await call_next(request)
    return response

In my own monitoring, this approach gives me enough information to correlate requests without accidentally turning log storage into a token graveyard.

Understand How Leaks Impact Your Python Backend

From the backend’s perspective, a leaked JWT is indistinguishable from a legitimate one. Your Python API happily accepts it as long as the signature and claims are valid. That’s why I pair short token lifetimes and rotation with careful storage on the client and strict sanitization on the server.

For team-wide practices, I like to maintain a short checklist covering where tokens may appear (logs, traces, metrics, browser tools) and periodically review it as we add new services. JWT Security Best Practices:Checklist for APIs | Curity

5. Storing JWTs Unsafely in Clients and Logs - image 1

6. No Key Rotation or Algorithm Migration Strategy

Among the more subtle JWT security mistakes in Python is treating your signing key and algorithm as “set and forget.” I’ve walked into projects where the same HS256 secret had been in use for years, shared across multiple services, with no plan for what happens if it’s ever exposed. That turns a single leak into a long-lived, system-wide compromise.

Keys and algorithms are part of your security posture, and they need a lifecycle. Rotation limits the window in which a stolen key is useful. Planning for algorithm changes—like moving from HS256 to RS256—keeps you from getting stuck on older, weaker, or poorly managed setups.

Design Tokens to Survive Rotation

In my own APIs, I embed a key ID (kid) and pin the algorithm so I can verify tokens against multiple keys during a migration window:

import os
import jwt

KEYS = {
    "v1": os.environ["JWT_KEY_V1"],
    "v2": os.environ["JWT_KEY_V2"],  # new key during rotation
}
CURRENT_KID = "v2"
ALGORITHM = "HS256"


def create_token(sub: str) -> str:
    payload = {"sub": sub}
    headers = {"kid": CURRENT_KID}
    return jwt.encode(payload, KEYS[CURRENT_KID], algorithm=ALGORITHM, headers=headers)


def decode_token(token: str) -> dict:
    unverified_header = jwt.get_unverified_header(token)
    kid = unverified_header.get("kid")
    key = KEYS.get(kid)
    if not key:
        raise ValueError("Unknown key id")
    return jwt.decode(token, key, algorithms=[ALGORITHM])

This pattern has saved me more than once: I can introduce a new key (or switch algorithms behind a gateway) while old tokens still work for a short period, instead of doing risky “flag day” cutovers.

7. Rolling Your Own JWT or Crypto Logic in Python

Out of all the JWT security mistakes in Python I’ve seen, the most dangerous is developers deciding to “keep it simple” and hand-roll their own JWT parsing or crypto. Every time I’ve reviewed code that manually base64-decodes segments, concatenates strings, and calls low-level crypto primitives, it’s been riddled with subtle bugs: missing checks, wrong algorithms, or insecure randomness.

JWT is deceptively straightforward, but the security details are not. Libraries like PyJWT and python-jose encode years of hard lessons, interoperability quirks, and corner cases. Reimplementing that with a few helper functions is asking for trouble.

Use Well-Maintained Libraries, Not Ad-Hoc Helpers

I’ve seen code like this in real projects:

# ❌ Homegrown JWT verification (do not copy)
import hmac
import hashlib
import base64
import json

SECRET = b"supersecret"


def insecure_verify(token: str) -> dict:
    header_b64, payload_b64, signature_b64 = token.split(".")
    signing_input = f"{header_b64}.{payload_b64}".encode()
    expected_sig = hmac.new(SECRET, signing_input, hashlib.sha256).digest()
    if base64.urlsafe_b64encode(expected_sig).rstrip(b"=") != signature_b64.encode():
        raise ValueError("bad signature")
    # No exp/iss/aud checks, no alg checks, no error handling
    payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==")
    return json.loads(payload_bytes)

Compared to this, a well-reviewed PyJWT call is shorter and safer. My rule now is: if I catch myself touching raw crypto primitives or manual JWT parsing in production code, I stop and replace it with a mature library instead.

If your team is unsure which Python JWT libraries and crypto backends are considered trustworthy and well-maintained, it’s worth consulting a curated security resource before committing to one. Comprehensive Empirical Study of Python JWT Libraries

7. Rolling Your Own JWT or Crypto Logic in Python - image 1

Conclusion: Hardening JWT Security in Your Python APIs

Looking back over the most common JWT security mistakes in Python I’ve encountered, the pattern is clear: it’s rarely one catastrophic bug, but a handful of small shortcuts that add up to serious risk. The good news is that a focused checklist goes a long way.

Here’s the quick pass I now apply to every Python API that uses JWTs:

  • Verify algorithms explicitly and never allow none or unexpected algs.
  • Enforce claim validation for exp, iss, and aud on every request.
  • Treat JWTs as readable: keep sensitive data out of claims unless you add real encryption.
  • Store tokens safely: prefer HTTP-only cookies, avoid URLs, and sanitize logs and traces.
  • Rotate signing keys and plan for algorithm migrations using kid and multi-key verification.
  • Rely on mature libraries like PyJWT instead of custom JWT or crypto code.

In my own projects, baking these points into shared helpers and documentation has dramatically reduced auth-related incidents. If you adopt the same mindset—JWTs as part of a living, maintained security surface—your Python APIs will be much harder to attack and far easier to reason about when something does go wrong.

Join the conversation

Your email address will not be published. Required fields are marked *