JSON Web Tokens (JWTs) are compact, self-contained credentials used to transmit claims between two parties. They are the backbone of stateless API authentication -- instead of storing session data on the server, the token itself carries everything needed to verify a user's identity and permissions. This guide covers how to create, sign, and verify JWTs in Python using PyJWT (the language's leading JWT library), with working code for both symmetric and asymmetric algorithms, grounded in the specifications defined by RFC 7519 and the security guidance of RFC 8725 (JWT Best Current Practices).
Unlike opaque tokens (random strings that require a server-side lookup), JWTs are structured. The token itself contains the user's identity, their permissions, and an expiration time -- all digitally signed so the server can verify nothing has been tampered with. This stateless design makes JWTs popular for REST APIs, microservices, and single sign-on (SSO) systems where keeping session state on every server is impractical. However, this same self-contained nature means that a misconfigured JWT implementation can open serious security vulnerabilities, which is why understanding the internals matters. RFC 8725 acknowledges this directly, noting that since the JWT specification was published, multiple widely documented attacks have exploited gaps in security mechanisms, incomplete library implementations, and misuse at the application layer.
How JWTs Work: Header, Payload, and Signature
A JWT is a string made up of three Base64URL-encoded sections separated by dots: header.payload.signature. The header declares the token type (JWT) and the signing algorithm (such as HS256 or RS256). The payload contains claims -- key-value pairs that carry information about the user, the issuer, and the token's validity period. The signature is computed from the header and payload using a secret key or private key, and it is what prevents anyone from modifying the token's contents undetected. This structure is defined precisely in RFC 7515 (JSON Web Signature) and RFC 7519 (JSON Web Token).
An important distinction: JWTs are signed, not encrypted. Anyone can decode the header and payload with a simple Base64 operation. The signature only guarantees integrity -- that the contents have not been changed since the token was issued. Never place sensitive information like passwords, credit card numbers, or personal identification numbers in a JWT payload.
If you need the payload contents to be unreadable, you need JSON Web Encryption (JWE), not a standard JWT (JWS). PyJWT handles JWS. For JWE in Python, look at the jwcrypto or authlib libraries. The distinction between JWS and JWE is defined in RFC 7516.
Think of a JWT as a postcard, not a sealed letter. Anyone who handles it can read the message written on the front (the payload). The signature is like a wax seal on the back -- it proves the postcard came from the claimed sender and was not altered in transit, but it does nothing to hide the contents. If you need a sealed letter, you need JWE (encryption), not JWS (signing). Every security decision in the rest of this guide flows from this distinction: you are protecting integrity and authenticity, not confidentiality.
Before building up each defense, it helps to see the full attack chain that these protections are designed to break. Each section of this guide targets a specific link in this chain.
role claim from "editor" to "admin" and re-signing with the forged algorithm. The server accepts the token. (Prevented by: claims validation and algorithm enforcement)
exp. (Prevented by: token revocation)
Installing PyJWT and Generating Your First Token
PyJWT is the leading JWT library for Python. The latest release (2.12.1, published March 13, 2026) supports Python 3.9 through 3.14. Install it with pip. If you plan to use asymmetric algorithms like RS256, include the cryptography extra:
# Basic install (HS256 only)
pip install PyJWT
# With cryptographic extras (RS256, ES256, EdDSA, etc.)
pip install "PyJWT[crypto]"
Here is the simplest possible example -- encoding a payload and decoding it back:
import jwt
# Encode a payload into a JWT
token = jwt.encode(
{"user_id": 42, "role": "editor"},
"my-secret-key",
algorithm="HS256",
)
print(token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Decode and verify the token
payload = jwt.decode(
token,
"my-secret-key",
algorithms=["HS256"],
)
print(payload)
# {'user_id': 42, 'role': 'editor'}
Notice that jwt.encode() takes a singular algorithm parameter (the one algorithm used to sign), while jwt.decode() takes a plural algorithms parameter (the list of algorithms the verifier will accept). This distinction is deliberate and important for security, as we will see in the section on algorithm confusion attacks. The PyJWT documentation warns against deriving the algorithms parameter from the token header or any attacker-controllable data, as doing so can expose the application to algorithm confusion and related vulnerabilities described in RFC 8725 Section 2.1.
Understanding Registered Claims
The JWT specification (RFC 7519, Section 4.1) defines a set of registered claims -- standardized keys with specific meanings that all JWT libraries understand. While none are mandatory, using them correctly is essential for a secure implementation. RFC 8725 Section 3.1 goes further, recommending that applications always include and validate the iss (issuer) and aud (audience) claims to prevent token substitution attacks across different systems.
| Claim | Name | Purpose |
|---|---|---|
iss | Issuer | Identifies who issued the token (your auth server) |
sub | Subject | Identifies the principal (typically the user ID) |
aud | Audience | Identifies the intended recipient (your API) |
exp | Expiration | Unix timestamp after which the token is invalid |
nbf | Not Before | Unix timestamp before which the token is invalid |
iat | Issued At | Unix timestamp of when the token was created |
jti | JWT ID | Unique identifier for the token (for revocation and replay prevention) |
Starting in PyJWT 2.12.0, the library validates the sub claim as a string and the jti claim as a string when those claims are present and their respective verification options are enabled. This means PyJWT now catches malformed tokens where sub or jti contain non-string values (such as integers), which could cause downstream type confusion bugs.
Signing with HS256: Symmetric Keys
HS256 (HMAC with SHA-256) is a symmetric algorithm, meaning the same secret key is used to both sign and verify the token. This is the simplest approach and works well when a single server handles both issuing and verifying tokens. The critical requirement is that the secret must be long enough -- at least 256 bits (32 bytes) as specified in RFC 7518 Section 3.2 -- and generated using a cryptographically secure method. PyJWT 2.12.0 now enforces this by default with minimum key length validation.
import jwt
import datetime
import os
import secrets
# Generate a strong secret (do this once, store securely)
# SECRET_KEY = secrets.token_hex(32)
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
def create_access_token(user_id, role, expires_minutes=30):
"""Create a signed JWT with standard claims."""
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iss": "pythoncodecrack.com",
"aud": "api.pythoncodecrack.com",
"iat": now,
"exp": now + datetime.timedelta(minutes=expires_minutes),
"jti": secrets.token_hex(16),
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# Generate a token
token = create_access_token(user_id=42, role="editor")
print(token)
Never use short, guessable strings like "secret" or "password123" as your HS256 key. Attackers use brute-force tools to test common secrets against captured tokens. Generate your key with secrets.token_hex(32) and store it in an environment variable or secrets manager. PyJWT 2.12.0 will emit an InsecureKeyLengthWarning if your HMAC key is shorter than 32 bytes -- pass options={"enforce_minimum_key_length": True} to your PyJWT instance to make this a hard error instead.
Signing with RS256: Asymmetric Keys
Connecting the threads: HS256 works when the same entity that signs the token is the same entity that verifies it. But the moment you introduce a second service -- a microservice, a mobile app, a third-party consumer -- sharing a secret becomes a liability. Every service that holds the secret can forge tokens. RS256 solves this by splitting the capability: one key signs, a different key verifies. The sections on JWKS key discovery and algorithm selection build directly on this distinction.
RS256 (RSA with SHA-256) uses a private key to sign tokens and a separate public key to verify them. This is the preferred algorithm for production systems, distributed architectures, and any scenario where multiple services need to verify tokens without sharing the signing secret. The private key stays on the authentication server; the public key can be distributed freely to any service that needs to validate tokens. RSA keys must be at least 2048 bits per NIST SP 800-131A; PyJWT 2.12.0 now validates this automatically.
# First, generate an RSA key pair (run once):
# openssl genpkey -algorithm RSA -out private_key.pem \
# -pkeyopt rsa_keygen_bits:2048
# openssl rsa -pubout -in private_key.pem -out public_key.pem
import jwt
import datetime
import secrets
# Load keys from files (in production, use a secrets manager)
with open("private_key.pem", "r") as f:
private_key = f.read()
with open("public_key.pem", "r") as f:
public_key = f.read()
def create_token_rs256(user_id, role):
"""Sign a JWT with the RS256 algorithm."""
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iss": "auth.pythoncodecrack.com",
"aud": "api.pythoncodecrack.com",
"iat": now,
"exp": now + datetime.timedelta(minutes=15),
"jti": secrets.token_hex(16),
}
return jwt.encode(payload, private_key, algorithm="RS256")
def verify_token_rs256(token):
"""Verify and decode a JWT using the public key."""
return jwt.decode(
token,
public_key,
algorithms=["RS256"], # Whitelist only RS256
audience="api.pythoncodecrack.com",
issuer="auth.pythoncodecrack.com",
)
token = create_token_rs256(user_id=42, role="admin")
claims = verify_token_rs256(token)
print(claims["sub"]) # "42"
For even stronger security and better performance, consider ES256 (ECDSA with P-256) or EdDSA. These algorithms produce shorter signatures and are faster to verify than RS256. PyJWT supports both when installed with the [crypto] extra. RFC 7518 Section 3.1 lists the full set of digital signature algorithms available for JWS.
Decoding and Verifying Tokens
When your API receives a request with a JWT in the Authorization: Bearer header, it needs to verify the token before trusting its contents. Verification means checking the signature, validating claims like exp, iss, and aud, and confirming that the algorithm matches what you expect. PyJWT handles all of this in a single jwt.decode() call when configured correctly.
import jwt
def verify_and_extract(token, secret_or_key):
"""Verify a JWT and return the claims, or None on failure."""
try:
claims = jwt.decode(
token,
secret_or_key,
algorithms=["HS256"],
issuer="pythoncodecrack.com",
audience="api.pythoncodecrack.com",
options={
"require": ["exp", "iss", "aud", "sub"],
},
)
return claims
except jwt.ExpiredSignatureError:
print("Token has expired.")
except jwt.InvalidAudienceError:
print("Token audience does not match.")
except jwt.InvalidIssuerError:
print("Token issuer does not match.")
except jwt.InvalidSignatureError:
print("Token signature is invalid.")
except jwt.MissingRequiredClaimError as e:
print(f"Token is missing a required claim: {e}")
except jwt.DecodeError:
print("Token is malformed.")
except jwt.InvalidTokenError as e:
print(f"Token validation failed: {e}")
return None
The options={"require": [...]} parameter tells PyJWT to reject any token that is missing the listed claims. Without this, a token with no exp claim would be accepted as valid indefinitely. Always require the claims your application depends on. Note the specific jwt.MissingRequiredClaimError exception -- this is raised when a required claim is absent, separate from the validation errors raised when a claim is present but invalid.
Handling Expiration and Validation Errors
Access tokens should be short-lived. RFC 8725 and industry guidance from organizations like OWASP recommend keeping access token lifetimes as short as practical -- typically 5 to 30 minutes for web and API contexts. Setting short expiration windows reduces the damage if a token is stolen, since the attacker has a limited time to use it. PyJWT enforces the exp claim automatically -- if the current time is past the expiration, jwt.decode() raises jwt.ExpiredSignatureError.
import jwt
import datetime
import os
import secrets
SECRET = os.environ["JWT_SECRET_KEY"]
def issue_token_pair(user_id):
"""Issue both an access token and a refresh token."""
now = datetime.datetime.now(datetime.timezone.utc)
access_token = jwt.encode(
{
"sub": str(user_id),
"type": "access",
"exp": now + datetime.timedelta(minutes=15),
"jti": secrets.token_hex(16),
},
SECRET,
algorithm="HS256",
)
refresh_token = jwt.encode(
{
"sub": str(user_id),
"type": "refresh",
"exp": now + datetime.timedelta(days=7),
"jti": secrets.token_hex(16),
},
SECRET,
algorithm="HS256",
)
return access_token, refresh_token
When the access token expires, the client presents the refresh token to obtain a new access token without re-entering credentials. The refresh token has a longer lifetime but should be stored securely on the server side and rotated after each use. If a refresh token is used more than once, it may indicate theft -- revoke the entire token chain. The jti claim is critical here: by storing each jti in a database or cache, you can detect and block replay attacks where an attacker reuses a captured token.
In distributed systems, server clocks are not always perfectly synchronized. PyJWT supports a leeway parameter (in seconds or as a datetime.timedelta) on jwt.decode() that provides a small margin of tolerance when checking exp and nbf claims. A leeway of 5-10 seconds is common. Example: jwt.decode(token, key, algorithms=["HS256"], leeway=5).
Where to Store JWTs on the Client
localStorage is a pocket -- anything your hand can reach, a pickpocket's hand can reach too. Any JavaScript on the page (including injected scripts) has full access. An httpOnly cookie is a hotel safe -- the browser manages it, and JavaScript cannot open it. The in-memory hybrid goes further: the access token exists only in your hand for the brief moment you need it. When you put your hand down (page reload), it is gone, and you return to the safe (refresh cookie) to get a new one. The attack anatomy at the top of this guide starts with Step 1: token theft from localStorage. This section is where you close that door.
Once your server issues a JWT, the client needs to store it somewhere and include it in subsequent requests. This storage decision has significant security implications, and the article would be incomplete without addressing it. There are three common approaches, each with distinct tradeoffs between convenience and attack surface.
localStorage is the approach seen in tutorials because it is the simplest to implement -- call localStorage.setItem("token", jwt) and read it back with getItem(). The problem is that localStorage is fully accessible to any JavaScript running on the page. If an attacker finds a cross-site scripting (XSS) vulnerability in your application, a single line of injected code can exfiltrate the token and send it to an external server. The OWASP Session Management Cheat Sheet recommends against storing session identifiers in localStorage for this reason.
httpOnly cookies are the more secure alternative for browser-based applications. When the server sets a cookie with the HttpOnly, Secure, and SameSite=Lax (or Strict) flags, JavaScript cannot read the cookie at all -- it is sent automatically with every request to the same domain. This blocks the straightforward XSS exfiltration attack. The tradeoff is that cookies introduce exposure to cross-site request forgery (CSRF), which must be mitigated with anti-CSRF tokens or relying on the SameSite attribute. Cookies are also limited to approximately 4 KB, which is usually enough for a JWT but can become a constraint with large payloads.
# Example: Setting JWT in an httpOnly cookie (Flask)
from flask import make_response
def login_response(access_token):
"""Return the JWT in a secure httpOnly cookie."""
response = make_response({"message": "Login successful"})
response.set_cookie(
"access_token",
access_token,
httponly=True, # JavaScript cannot access this cookie
secure=True, # Only sent over HTTPS
samesite="Lax", # Mitigates CSRF for top-level navigations
max_age=900, # 15 minutes, matches token exp
)
return response
In-memory storage (holding the access token in a JavaScript variable or React state) combined with an httpOnly cookie for the refresh token is considered the current best-practice hybrid. The access token never touches persistent browser storage, so it cannot be stolen by XSS from localStorage or from a cookie. When the page reloads, the access token is gone -- the client silently hits the refresh endpoint, the browser sends the httpOnly refresh cookie automatically, and the server issues a new short-lived access token. This pattern eliminates the XSS risk to the access token and limits the CSRF risk to the refresh endpoint, which can be protected with standard anti-CSRF measures.
For browser-based applications, use httpOnly cookies or the in-memory/cookie hybrid. Reserve localStorage for tokens with limited scope or non-sensitive applications where the convenience outweighs the risk. For native mobile apps and server-to-server communication, tokens are typically stored in secure platform storage (Keychain on iOS, Keystore on Android) or kept in process memory, and the browser storage debate does not apply.
Revoking Tokens Before Expiration
The paradox at the center of JWT design: The entire value proposition of JWTs is statelessness -- the server does not need to store session data. But the moment you need to revoke a token (and you will), you re-introduce state. The question is not whether to add state, but how little state you can add while still getting the emergency brake you need. This section addresses Step 5 of the attack anatomy above: the attacker who maintains access long after the legitimate user has logged out.
Short-lived tokens reduce the window of exposure, but they do not eliminate it. Consider a scenario where a user's account is compromised, or an employee leaves the organization, or a permission change needs to take effect immediately. The token is still valid until exp. Because JWTs are stateless and self-contained, there is no session record on the server to delete -- the token carries its own proof of validity. This is the fundamental revocation problem with JWTs, and every production deployment must address it.
The standard approach is a token blocklist (sometimes called a denylist or blacklist). When a token needs to be revoked -- at logout, after a password change, or by an administrator -- its jti (JWT ID) is written to a fast data store like Redis with a TTL matching the token's remaining lifetime. On every request, the API checks the blocklist before accepting the token. This adds a single key lookup per request, which Redis handles in sub-millisecond time.
import jwt
import redis
import datetime
redis_client = redis.StrictRedis(host="localhost", port=6379, db=0)
def revoke_token(token, secret_key):
"""Add a token's jti to the blocklist in Redis."""
claims = jwt.decode(token, secret_key, algorithms=["HS256"])
jti = claims["jti"]
exp = datetime.datetime.fromtimestamp(
claims["exp"], tz=datetime.timezone.utc
)
remaining = exp - datetime.datetime.now(datetime.timezone.utc)
# Store the jti with a TTL so it auto-clears after expiration
redis_client.setex(
f"blocklist:{jti}",
int(remaining.total_seconds()),
"revoked",
)
def is_token_revoked(claims):
"""Check whether a token's jti is in the blocklist."""
return redis_client.exists(f"blocklist:{claims['jti']}")
An alternative strategy is token versioning: store a version counter per user in the database and include that version in the JWT payload. When you need to revoke all tokens for a user (after a password reset, for example), increment the counter. On each request, compare the token's version against the current database value. If the token's version is stale, reject it. This approach is lighter than a full blocklist because it stores one integer per user rather than one entry per token, but it only supports user-level revocation, not individual token revocation.
Beyond Blocklists: Sender-Constraining with DPoP
Both the blocklist and versioning approaches share a fundamental limitation: they attempt to revoke a token after theft has occurred. A stronger approach prevents the stolen token from being usable in the first place. Demonstrating Proof-of-Possession (DPoP), defined in RFC 9449, achieves this by binding each token to a client-held cryptographic key. Instead of treating the JWT as a bearer token (where anyone who possesses it can use it), DPoP requires the client to generate a fresh, signed proof JWT for every request -- proving it holds the private key that the token was originally bound to. If an attacker steals the access token but does not have the client's private key, the token is useless: the resource server will reject any request where the DPoP proof signature does not match the key thumbprint embedded in the token's cnf (confirmation) claim.
DPoP is particularly valuable for public clients (single-page applications, mobile apps) that cannot securely store client secrets. The client generates an asymmetric key pair, includes the public key in the DPoP proof sent with the token request, and the authorization server binds the issued token to that key. On subsequent API calls, the client signs a new DPoP proof containing the HTTP method, target URI, and a hash of the access token. The resource server verifies that the proof's public key matches the bound key and that the signature is valid. This sender-constraining happens at the application layer, requiring no changes to TLS infrastructure -- unlike mutual TLS (mTLS), which achieves similar binding but demands certificate management and PKI overhead that is impractical for browser-based clients.
Token Introspection for Centralized Revocation
For architectures where the authorization server is the single source of truth, OAuth 2.0 Token Introspection (RFC 7662) offers a different model. Instead of each resource server maintaining its own blocklist, the resource server sends the token to the authorization server's introspection endpoint on every request and receives an authoritative response indicating whether the token is currently active. This eliminates the distributed consistency problem of keeping blocklists synchronized across multiple services -- revocation is immediate and centralized. The tradeoff is latency: every API request incurs a network round-trip to the introspection endpoint. Caching the response for a few seconds mitigates this, but introduces a small window where a revoked token could still be accepted. In practice, introspection is often used alongside self-contained JWTs rather than replacing them: the JWT provides fast, stateless verification for the common case, and introspection is invoked only for high-risk operations or when the token is close to expiration.
Event-Driven Revocation Propagation
In large distributed systems with dozens of microservices, neither per-service blocklists nor centralized introspection may scale cleanly. A third approach uses Security Event Tokens (SETs), defined in RFC 8417, to push revocation events from the authorization server to all downstream services in near-real-time. When a user logs out, changes their password, or has their account suspended, the authorization server publishes a signed SET containing the event type and the affected token or user identifier. Each resource server subscribes to these events and updates its local state accordingly -- adding the revoked jti to a local blocklist, incrementing a version counter, or invalidating a cached introspection response. This event-driven model decouples the revocation decision from the enforcement point, allowing each service to maintain its own fast-path validation while receiving authoritative revocation signals without polling. The Push-Based SET Delivery (RFC 8935) and Poll-Based SET Delivery (RFC 8936) specifications define the transport mechanisms.
Adding a blocklist, introspection call, or version check means your "stateless" JWT now requires a server-side lookup on every request. This is intentional and unavoidable for any system that needs immediate revocation. The tradeoff is worthwhile: you keep the benefits of JWT structure (signed claims, no full session hydration) while gaining the ability to cut off compromised tokens. A Redis blocklist check adds roughly 0.1-0.5 ms per request -- negligible compared to the security cost of a stolen token with hours of remaining lifetime. DPoP takes a different approach entirely: rather than revoking after theft, it prevents the stolen token from being usable in the first place, at the cost of additional cryptographic operations on the client side. The right combination depends on your threat model, your architecture's tolerance for added latency, and whether your clients can manage asymmetric key pairs.
Preventing Algorithm Confusion Attacks
Imagine a building with two types of locks: a combination lock (symmetric -- anyone who knows the code can lock and unlock) and a deadbolt with separate keys (asymmetric -- one key locks, a different key unlocks). Algorithm confusion is the equivalent of an intruder telling the building that "this door uses a combination lock" and then entering the public deadbolt key as the combination. If the building trusts the intruder's claim about which lock type to use, the door opens. The fix is simple: the building decides which lock type each door uses, and ignores anyone who says otherwise. That is what the algorithms whitelist does.
Algorithm confusion is one of the dangerous JWT vulnerabilities identified in RFC 8725 Section 2.1. The attack works like this: your server signs tokens with RS256 (asymmetric). An attacker takes a valid token, changes the alg header to HS256 (symmetric), and signs it using your server's public key as the HMAC secret. If your verification code reads the algorithm from the token header instead of enforcing a whitelist, it will use the public key to verify the HMAC signature -- and the forged token will pass validation. This specific vulnerability was addressed in PyJWT via CVE-2022-29217.
The defense is straightforward: always pass an explicit algorithms list to jwt.decode(). This tells PyJWT to ignore whatever the token header claims and only accept the algorithms you specify. RFC 8725 requires that applications accept only cryptographically current algorithms that satisfy their security requirements and prohibits computing the algorithms parameter from the token itself.
# VULNERABLE -- trusts the token's algorithm header
# payload = jwt.decode(token, key) # NEVER do this
# SAFE -- explicitly whitelist allowed algorithms
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # Only accept RS256
)
PyJWT requires the algorithms parameter and will raise a DecodeError if it is not provided, which protects against the simplest form of this attack. However, you should also ensure you are not passing both your public key and a symmetric secret to the same decode call, which could reopen the vulnerability. The PyJWT documentation additionally cautions against mixing symmetric and asymmetric algorithms (such as HS256 and RS256) in the same decode call, since they interpret the key parameter in fundamentally different ways.
Minimum Key Length Enforcement and CVE-2026-32597
Why this matters beyond compliance: The algorithm confusion attack from the previous section exploits what the server accepts. The vulnerabilities in this section exploit what the server fails to reject -- weak keys that can be brute-forced, and critical header extensions that the library silently ignores. These are not theoretical concerns: CVE-2026-32597 was scored 7.5 (HIGH) because it allowed tokens carrying unprocessed security extensions to pass validation, meaning an attacker could craft a token with mandatory security constraints that PyJWT simply did not enforce.
PyJWT 2.12.0 introduced two significant security improvements that affect every deployment.
Minimum Key Length Validation (CWE-326)
PyJWT now validates that cryptographic keys meet minimum recommended lengths based on industry standards. For HMAC algorithms (HS256, HS384, HS512), the key must be at least as long as the hash output -- 32 bytes for HS256, 48 bytes for HS384, and 64 bytes for HS512 -- as specified in RFC 7518 Section 3.2. For RSA algorithms, the key must be at least 2048 bits per NIST SP 800-131A. By default, short keys produce an InsecureKeyLengthWarning. To enforce strict rejection:
import jwt
# Create a strict PyJWT instance that rejects short keys
strict_jwt = jwt.PyJWT(options={"enforce_minimum_key_length": True})
try:
strict_jwt.encode({"some": "payload"}, "short", algorithm="HS256")
except jwt.InvalidKeyError:
print("Key rejected: too short for HS256")
Critical Header Validation (CVE-2026-32597)
On March 13, 2026, a high-severity vulnerability (CVE-2026-32597, CVSS 7.5) was disclosed and patched in PyJWT 2.12.0. All previous versions failed to validate the crit (Critical) Header Parameter defined in RFC 7515 Section 4.1.11. The crit header is a list of extensions that the token issuer declares as mandatory for the verifier to understand. If a verifier does not support an extension listed in crit, it MUST reject the token. Versions before 2.12.0 silently accepted such tokens, violating the RFC requirement and potentially allowing tokens with unprocessed security-critical extensions to be treated as valid.
If you are running any version of PyJWT before 2.12.0, upgrade immediately: pip install --upgrade PyJWT. CVE-2026-32597 affects all previous versions and has a CVSS score of 7.5 (HIGH). The fix also includes minimum key length validation and ECDSA curve enforcement per RFC 7518 Section 3.4.
Automatic Key Discovery with PyJWKClient
From static to dynamic trust: The previous sections assumed you manage your own keys -- generating them, storing them, rotating them manually. But in production architectures that use external identity providers (Auth0, Okta, Azure AD, Keycloak), the signing keys are not yours to manage. They rotate on the provider's schedule, without notice. JWKS (JSON Web Key Set) is the mechanism that bridges this gap: the provider publishes its current keys at a well-known endpoint, and your application fetches them automatically. This is where the RS256 advantage from the asymmetric signing section pays off -- the provider publishes only public keys, so there is nothing secret to protect at the endpoint.
In production, you rarely hardcode public keys. Identity providers (Auth0, Okta, Azure AD, Keycloak) publish their signing keys at a JWKS (JSON Web Key Set) endpoint, and the signing key can rotate without notice. PyJWT includes PyJWKClient for automatic key discovery and caching. This approach is the standard pattern for verifying tokens from external identity providers and is described in RFC 7517 (JSON Web Key).
import jwt
from jwt import PyJWKClient
# The JWKS endpoint of your identity provider
JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
# Create a client (caches keys automatically)
jwks_client = PyJWKClient(JWKS_URL)
def verify_token_jwks(token):
"""Verify a JWT using keys fetched from a JWKS endpoint."""
# Automatically selects the correct key by matching
# the token's 'kid' (Key ID) header against the JWKS
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="api.pythoncodecrack.com",
issuer="https://auth.example.com/",
)
PyJWKClient handles key caching with a two-tier system: a JWK Set cache for the full keyset and an LRU cache for individual signing keys. When a token arrives with a kid that is not in the cache, the client automatically refreshes the keyset from the endpoint. In PyJWT 2.10.0 and later, you can also pass an ssl.SSLContext for certificate pinning in high-security environments, and custom HTTP headers for API gateways that require authentication on the JWKS endpoint itself.
Integrating PyJWT with a Web Framework
Every defense discussed so far -- algorithm whitelisting, claims validation, key verification, blocklist checks -- must execute before your business logic runs. The framework integration pattern is the bouncer: it stands at the entrance (the request boundary), checks the credential (the JWT), and either lets the request through with verified identity attached, or turns it away with a 401. If the bouncer is not at the door -- if verification happens inside the route handler, or worse, is optional -- then unverified claims can reach your application logic. The principle is: fail closed at the boundary, never inside the room.
The examples above demonstrate PyJWT in isolation, but production applications need JWT verification wired into the request lifecycle of a web framework. The pattern is consistent across frameworks: extract the token from the Authorization: Bearer header, decode and verify it with PyJWT, and either attach the authenticated user to the request context or return a 401 response. Here is a complete example using FastAPI, the framework that has become the default choice for Python APIs.
import jwt
import os
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
app = FastAPI()
security = HTTPBearer()
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""Dependency that verifies the JWT and returns the claims."""
token = credentials.credentials
try:
claims = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
issuer="pythoncodecrack.com",
audience="api.pythoncodecrack.com",
options={"require": ["exp", "sub", "iss", "aud"]},
)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
)
except jwt.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {e}",
)
return claims
@app.get("/protected")
def protected_route(user: dict = Depends(get_current_user)):
"""This endpoint requires a valid JWT."""
return {
"message": f"Hello, user {user['sub']}",
"role": user.get("role"),
}
The key design choice here is the dependency injection pattern. The get_current_user function runs before any route handler that depends on it. If verification fails, the framework returns 401 immediately -- the route handler never executes. This centralizes all JWT logic in one place, keeping individual endpoints clean. The same pattern applies to Flask (using @app.before_request or a custom decorator) and Django REST Framework (using a custom BaseAuthentication subclass). The critical principle is the same in every framework: verify the token at the boundary, fail closed on any error, and never let unverified claims reach your business logic.
Choosing Between HS256 and RS256
Tying the threads together: This choice is not just a performance or convenience question -- it ripples through every other decision in your JWT implementation. HS256 means every verifying service holds the signing secret (revisit the algorithm confusion implications). RS256 means you can use JWKS discovery for automatic key rotation. The defense layering section below shows how this single decision affects the strength of every other layer in your security stack.
The right algorithm depends on your architecture. Here is a practical comparison:
| Factor | HS256 (Symmetric) | RS256 (Asymmetric) |
|---|---|---|
| Key management | Single shared secret | Private key signs, public key verifies |
| Best for | Single-server apps, internal microservices with a shared secret | Distributed systems, public APIs, multi-service architectures |
| Risk if key leaks | Attacker can forge and verify tokens | Only the private key allows forging; public key exposure is expected |
| Performance | Faster signing and verification | Slower, but verification is fast with cached public keys |
| Key rotation | Requires updating every service with the new secret | Only the auth server needs the new private key; public key is published via JWKS |
| Min key size (PyJWT enforced) | 32 bytes (per RFC 7518 Section 3.2) | 2048 bits (per NIST SP 800-131A) |
For production APIs that serve external clients or multiple internal services, RS256 (or the faster ES256/EdDSA alternatives) is the recommended choice. HS256 remains viable for single-server setups where the signing and verification happen in the same trusted environment. RFC 8725 adds that applications must be designed for cryptographic agility, meaning you should be able to switch algorithms without a full rewrite if a weakness is discovered in your current choice.
How the Defenses Layer: Tracing a Stolen Credential
The individual sections above each address a specific vulnerability, but JWT security is not a checklist of independent items -- it is a layered defense where each protection compensates for the potential failure of another. To see why every layer matters, trace what happens when an attacker obtains a user's credentials and attempts to exploit each stage of the JWT lifecycle.
iss, aud, sub, and exp. A token minted for a different service (token substitution attack) or with a tampered subject claim is rejected before any business logic executes.jwt.decode() only accepts the whitelisted algorithm. Swapping alg: RS256 to HS256 and signing with the public key fails immediately. Algorithm confusion blocked.jti is written to the Redis blocklist. On the attacker's next request, the server finds the jti in the blocklist and returns 401. Revoked token rejected.kid and fetches the updated keyset automatically. Tokens signed with the old key are no longer valid. The attacker's stolen token, even if unexpired and unrevoked, fails signature verification.cnf claim. Without the matching private key, the stolen token is inert. Sender-constraining prevents replay.No single layer is impenetrable. localStorage can be read by XSS. Short lifetimes still leave a window. Blocklists require Redis availability. DPoP depends on the client's ability to protect its private key. The strength of the design comes from the interaction between layers: each one narrows the attacker's options, and together they reduce the exploitable surface to near zero. When you build a JWT implementation, the question to ask at each decision point is not "is this layer perfect?" but "what does this layer protect against that the others do not?"
Key Takeaways
- JWTs are signed, not encrypted: The payload is readable by anyone. Never store sensitive data in a JWT. The signature only guarantees that the contents have not been tampered with.
- Always whitelist algorithms in jwt.decode(): Pass an explicit
algorithms=["RS256"](or whichever algorithm you use) to prevent algorithm confusion attacks. Never let the token itself dictate how it should be verified. This is required by RFC 8725 Section 3.1 and enforced by PyJWT's API. - Use registered claims and require them: Set
exp,iss,aud,sub, andjtiwhen creating tokens. Use PyJWT'srequireoption to reject tokens missing critical claims. - Keep access tokens short-lived: Set expiration to 5-30 minutes. Use refresh tokens with rotation and single-use enforcement for longer sessions.
- Store tokens securely on the client: For browser-based applications, use httpOnly cookies with
SecureandSameSiteflags, or hold the access token in memory and store only the refresh token in an httpOnly cookie. Avoid localStorage for sensitive tokens because it is fully accessible to any JavaScript on the page, including scripts injected through XSS vulnerabilities. - Plan for token revocation from day one: JWTs are stateless, but production systems need the ability to invalidate tokens immediately -- on logout, after a password change, or when an account is compromised. Implement a blocklist backed by Redis (keyed on the token's
jtiwith a TTL matching the remaining lifetime), use token versioning for user-level revocation, or adopt DPoP (RFC 9449) to sender-constrain tokens so stolen credentials cannot be replayed without the client's private key. For distributed architectures, consider token introspection (RFC 7662) for centralized revocation or Security Event Tokens (RFC 8417) for event-driven propagation across services. - Choose the right algorithm for your architecture: Use RS256 or ES256 for distributed systems and public APIs. Use HS256 only for single-server environments with a strong, randomly generated secret of at least 32 bytes.
- Upgrade to PyJWT 2.12.0 or later: This version patches CVE-2026-32597 (crit header validation), adds minimum key length enforcement, validates sub and jti claim types, and enforces ECDSA curve validation per RFC 7518. Run
pip install --upgrade PyJWT. - Use PyJWKClient for key discovery: In production systems that verify tokens from external identity providers, use PyJWT's built-in JWKS client to automatically fetch and cache signing keys instead of hardcoding them.
JWTs give Python developers a powerful, standardized way to handle stateless authentication across APIs and services. PyJWT makes the mechanics of encoding, signing, and verifying straightforward -- but the security of your implementation depends on the choices you make around algorithm selection, claims validation, key management, and token lifetime. Get those right, and you have a robust authentication layer. Get them wrong, and the token that was supposed to protect your API becomes the attack vector.
Sources and References
- RFC RFC 7519 -- JSON Web Token (JWT) -- M. Jones, J. Bradley, N. Sakimura, IETF, May 2015
- RFC RFC 8725 -- JSON Web Token Best Current Practices -- Y. Sheffer, D. Hardt, M. Jones, IETF, February 2020
- RFC RFC 7515 -- JSON Web Signature (JWS) -- M. Jones, J. Bradley, N. Sakimura, IETF, May 2015
- RFC RFC 7518 -- JSON Web Algorithms (JWA) -- M. Jones, IETF, May 2015
- RFC RFC 7517 -- JSON Web Key (JWK) -- M. Jones, IETF, May 2015
- DRAFT draft-ietf-oauth-rfc8725bis -- JWT Best Current Practices (revision in progress) -- Y. Sheffer, D. Hardt, M. Jones, IETF, November 2025
- DOCS PyJWT 2.12.1 Documentation -- Jose Padilla et al.
- PYPI PyJWT on PyPI -- Version 2.12.1, Released March 13, 2026
- CVE CVE-2026-32597 -- PyJWT crit Header Validation Bypass -- CVSS 7.5 (HIGH), NVD, March 2026
- CVE CVE-2022-29217 -- PyJWT Algorithm Confusion -- GitHub Advisory, May 2022
- NIST NIST SP 800-131A Rev. 2 -- Transitioning the Use of Cryptographic Algorithms
- RFC RFC 9449 -- OAuth 2.0 Demonstrating Proof of Possession (DPoP) -- D. Fett, B. Campbell, J. Bradley, T. Lodderstedt, M. Jones, D. Waite, IETF, September 2023
- RFC RFC 7662 -- OAuth 2.0 Token Introspection -- J. Richer, IETF, October 2015
- RFC RFC 8417 -- Security Event Token (SET) -- P. Hunt, M. Jones, W. Denniss, M. Ansari, IETF, July 2018