Skip to content

CWE-347: Improper Verification of Cryptographic Signature - Python

Overview

PyJWT requires an explicit algorithms argument on every jwt.decode() call - omitting it raises DecodeError outright, which closes off the most basic form of algorithm confusion by default. The vulnerability reappears in two ways: passing an algorithms list broader than the single algorithm the issuer actually uses, and choosing the verification key based on the token's own unverified header (jwt.get_unverified_header(token)["alg"]). Current PyJWT also rejects alg: none as long as "none" is not in the list; older 1.x releases had CVEs there.

The classic RS256-as-HS256 forgery needs a caveat, because PyJWT has a specific guard against it and the guard has a hole. HMACAlgorithm.prepare_key refuses a key that looks like asymmetric key material - InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret - and the test is a text-shape check for a PEM header, an SSH key, or a JWK object. Measured on PyJWT 2.13.0: pass the RSA public key as PEM text and the forgery is refused; pass the same key as raw DER (public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)), which nothing in that release's guard recognises, and jwt.decode(forged, pub_der, algorithms=["RS256", "HS256"]) returns the attacker's claims. PyJWT 2.14.0 closed that spelling: prepare_key gained a DER check and refuses the same key with the same InvalidKeyError - measured, so on a current release neither encoding of an RSA key gets through. It is still a shape check on the key bytes rather than a type check on the key, and the fix is the same either way: do not put two families in one list.

For non-JWT signatures - webhook payloads, custom API signing schemes - Python code often uses hmac.new(...) correctly to compute the digest but then compares it with ==, which is not constant-time. And when verifying asymmetric signatures directly with the cryptography library, public_key.verify(...) raises InvalidSignature on failure rather than returning a boolean - code that wraps the call in a broad try/except and treats any exception as "handled" (logging and continuing) can accidentally treat a failed verification as success.

The safe replacements are: pin algorithms to the single expected algorithm and never derive it from the token, resolve keys by kid from a trusted keystore, use hmac.compare_digest() over bytes rather than str for HMAC comparisons, and let cryptography's verify() exceptions propagate as verification failures.

Common Vulnerable Patterns

Mixed Algorithm List Reusing the Same Key

import jwt

# VULNERABLE - algorithms mixes RSA and HMAC, and one key argument is
# supplied for both families
def verify_access_token(token: str, rsa_public_key_der: bytes) -> dict:
    return jwt.decode(token, rsa_public_key_der, algorithms=["RS256", "HS256"])

# Attack: take a legitimate RS256 token, change the header to {"alg":"HS256"},
# and re-sign it with HMAC-SHA256 using rsa_public_key_der as the secret. The
# public key is not secret, so anyone holding a copy can mint tokens.
#
# The variant with a broad list inside ONE family needs no such trick: with
# algorithms=["RS256", "RS512"] an RS512 token is simply accepted.

Why this is vulnerable: algorithms prevents PyJWT from accepting an algorithm outside the list, but it does not stop the list itself from being too broad. A list spanning both symmetric and asymmetric families, paired with one key argument, reopens the confusion PyJWT's mandatory-algorithms check was meant to close.

Note which encoding the key is in and which PyJWT is installed, because together they decide whether this exact code is exploitable. Measured on PyJWT 2.13.0, the same call with a PEM-encoded public key raises InvalidKeyError - HMACAlgorithm.prepare_key refuses anything whose bytes begin with a PEM or SSH header, or parse as a JWK - while a DER-encoded key sails straight through and the forged token is accepted. Measured on 2.14.0, the DER key is refused as well: the guard gained a DER check in that release. It remains a shape check on the bytes rather than a type check on the key, so do not treat it as the reason the code is safe; the reason to fix this is the list.

Selecting the Key From the Token's Own Header

import jwt

# VULNERABLE - the unverified header decides which key to use
def verify_access_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header["alg"] == "HS256":
        key = shared_hmac_secret
    else:
        key = rsa_public_key_pem
    return jwt.decode(token, key, algorithms=[header["alg"]])

Why this is vulnerable: jwt.get_unverified_header() returns exactly what its name says - unverified, attacker-controlled data. Using it to choose both the key and the algorithm passed to decode() means the token fully controls its own verification path.

Broad Exception Handling Around cryptography's verify()

from cryptography.exceptions import InvalidSignature

# VULNERABLE - a bare except swallows InvalidSignature and lets execution
# continue as if verification had succeeded
def process_signed_payload(public_key, signature: bytes, data: bytes):
    try:
        public_key.verify(signature, data, padding, hashes.SHA256())
    except Exception as e:
        log.warning("verify() raised: %s", e)
    handle_payload(data)  # runs regardless of whether verification succeeded

Why this is vulnerable: verify() in the cryptography library communicates failure by raising InvalidSignature, not by returning False. Code that catches the exception, logs it, and falls through to the next statement treats a rejected signature exactly the same as an accepted one.

Secure Patterns

Pin the Algorithm to a Single Expected Value

import jwt

# SECURE - algorithm is pinned; the token header cannot select RS512 instead
def verify_access_token(token: str, rsa_public_key) -> dict:
    return jwt.decode(
        token,
        rsa_public_key,
        algorithms=["RS256"],
        issuer="https://issuer.example.com",
        audience="my-api",
        options={"require": ["exp", "iss", "aud"]},
    )

Why this works: With algorithms containing exactly one value, PyJWT rejects a token whose header claims any other algorithm before signature verification runs, and the key argument only ever needs to be valid for that one algorithm - there is no second algorithm the same key could be mistakenly used to satisfy. Verified on 2.13.0: an RS512 token that algorithms=["RS256", "RS512"] accepts raises InvalidAlgorithmError: The specified alg value is not allowed here, while the legitimate RS256 token is still accepted.

options={"require": [...]} is doing separate work. PyJWT validates exp, iss and aud when the claim is present and accepts a token that simply omits it - measured, a token with no exp decodes without complaint. Listing the claims you rely on turns their absence into MissingRequiredClaimError rather than a silent pass.

Resolve Keys by kid From a Trusted Keystore, Never From the Token

import jwt

# SECURE - kid is used only as a lookup key into a trusted, server-side store;
# the algorithm expectation is fixed and never taken from the token
def verify_access_token(token: str) -> dict:
    unverified_header = jwt.get_unverified_header(token)
    kid = unverified_header.get("kid")
    public_key = trusted_key_store.get_rsa_public_key(kid)  # raises if kid is unknown
    return jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        issuer="https://issuer.example.com",
        audience="my-api",
    )

Why this works: The only value taken from the unverified header is kid, used strictly as an index into a keystore populated ahead of time from a trusted source. The algorithm passed to decode() is a hardcoded literal, not derived from the header, so a kid that happens to collide with a differently-typed key cannot change what algorithm the token is checked against.

The audience argument is not optional once the issuer puts an aud claim in its tokens. PyJWT treats a token that carries aud as one that must be checked against an expected audience, and decode() called without one raises InvalidAudienceError: Invalid audience - measured on 2.14.0, the issuer's own legitimate token is refused. A keystore example that omits it rejects everything the pinned example above accepts, which is the fail-that-looks-like-security shape the Start with the accept step on the CWE-347 page exists to catch.

kid is the only header parameter that can be used this way. jku, x5u, jwk and x5c name the source of the key rather than an entry in a source you already trust, so a resolver that honours one accepts a key pair the sender generated and verifies their token against it. jku and x5u also mean the verification path fetches a URL taken from an unauthenticated token, which is CWE-918 on your authentication endpoint. If you use PyJWKClient, construct it with a fixed JWKS URL and never with one read off the token.

Let verify() Exceptions Propagate as Verification Failures

from cryptography.exceptions import InvalidSignature

# SECURE - InvalidSignature is not caught here; it propagates to the caller,
# which must treat it as a rejected request
def process_signed_payload(public_key, signature: bytes, data: bytes):
    try:
        public_key.verify(signature, data, padding, hashes.SHA256())
    except InvalidSignature:
        raise SecurityError("Invalid signature") from None
    handle_payload(data)  # only reached if verify() did not raise

Why this works: Catching only InvalidSignature (not a bare Exception) and re-raising an unambiguous error means there is exactly one path to handle_payload(): a successful verify() call. Any failure, expected or not, stops execution before the payload is treated as trusted.

Constant-Time Comparison for Webhook HMAC Signatures

import hmac
import hashlib

# SECURE - webhook HMAC-SHA256 verification with constant-time comparison
def verify_webhook_signature(request_body: bytes, signature_header: str, webhook_secret: bytes) -> bool:
    expected = hmac.new(webhook_secret, request_body, hashlib.sha256).digest()

    # Decode to bytes first. compare_digest raises TypeError if either str
    # argument contains a non-ASCII character, and the header is the attacker's
    # to choose; bytes.fromhex also rejects an odd length or a non-hex digit.
    try:
        provided = bytes.fromhex(signature_header)
    except ValueError:
        return False

    return hmac.compare_digest(expected, provided)

Why this works: hmac.compare_digest() is implemented specifically to avoid content-dependent short-circuiting, so the comparison takes the same time regardless of where (or whether) the two values first differ, and it returns False rather than raising when the two arguments are of different lengths. == on strings returns at the first difference, so the time it takes reflects how much of the submitted signature matched. CPython compares a machine word at a time rather than a character, so the residual signal is much coarser than "one byte per attempt" implies - see CWE-208 for the measurements and for why the fix is worth making anyway.

The decode step is load-bearing, not tidying. compare_digest accepts str arguments only if both are ASCII-only, and raises TypeError: comparing strings with non-ASCII characters is not supported otherwise. A WSGI server decodes header values as latin-1, so any byte above 0x7F in the signature header arrives as a non-ASCII character and the comparison raises. Measured against a real Flask server on Python 3.13: with the header compared as a str, a signature of 0xFF followed by 63 zeros returns 500, as does one whose first two bytes are the UTF-8 encoding of an accented letter - while a wrong-but-ASCII signature correctly returns 401. The malicious input, and only the malicious input, reaches the crash, so every accept test and every wrong-signature test passes over the top of it. Comparing bytes removes the failure mode entirely: with the version above, all of the legitimate, wrong, short, odd-length, non-hex, non-ASCII and empty cases return a plain True/False.

Framework-Specific Guidance

Flask: Verify Against the Raw Request Body

from flask import Flask, request, abort
# The verifier from the section above, as its own module
from webhook_signature import verify_webhook_signature

app = Flask(__name__)

@app.route("/webhooks/provider", methods=["POST"])
def webhook():
    signature_header = request.headers.get("X-Signature", "")
    raw_body = request.get_data()  # exact bytes received, before any JSON parsing

    # SECURE - verify before touching the parsed payload
    if not verify_webhook_signature(raw_body, signature_header, webhook_secret):
        abort(401)

    payload = request.get_json()
    # process payload
    return "", 204

Why this works: request.get_data() returns the raw bytes Flask received. Verifying against those bytes - rather than json.dumps(request.get_json()) - guarantees the signature check runs against the exact content the sender signed, avoiding both false rejections from re-serialization differences and a mismatch between what was verified and what gets processed.

Considerations

This is one of the few findings that is almost never a false positive. For most weaknesses the first question is whether the value is security-relevant; here, if a signature is being checked at all, something is trusting the result. The narrow exception is data that never crossed a trust boundary - a token your own process minted, held in memory, and verified moments later. If the token arrived over the network, the check matters.

Decide where verification happens, and whether once is enough. A gateway that verifies tokens before forwarding lets backend services skip the work, which is efficient and fine until one service becomes reachable another way - an internal caller, a service mesh retry, a debugging port. Verifying again in the service costs little and does not depend on network topology staying as drawn. If you do rely on the gateway, make it impossible to bypass rather than merely inconvenient.

Symmetric algorithms give every verifier the power to mint. HS256 uses one shared secret, so any service holding it to check tokens can also issue them. With three services and one secret you have three places a forged administrator token can come from. RS256 and EdDSA split that: the issuer holds the private key, verifiers hold only the public one. If more than one service verifies, that separation is worth the extra key management.

Set leeway deliberately and require the claims you rely on. PyJWT applies no skew allowance unless you pass leeway, and it will happily accept a token with no exp at all unless you ask for it. options={"require": ["exp", "iss", "aud"]} makes the absence of a claim a failure rather than a silent pass.

Key rotation needs a cache policy decided in advance. Resolving keys by kid from a JWKS endpoint means an outbound fetch on the verification path. Cache too briefly and every request becomes a network call, so an issuer outage takes your authentication down with it; cache too long and a rotated-away key stays trusted. Cache by kid with a refresh on unknown values, plus a floor on how often that refresh can fire, so an attacker cannot drive fetches by sending tokens with random kid values.

Expiry is not revocation. Signature verification proves a token was issued and unmodified; it says nothing about whether the account was disabled a minute ago. Short lifetimes narrow that window and cost a refresh round trip; a revocation list closes it and costs a lookup on every request. Which you need depends on how quickly access must actually stop - "immediately" and "within fifteen minutes" are different systems.

Testing

  • Normal: a legitimately issued RS256 token and a correctly HMAC-signed webhook payload are both accepted.
  • Boundary: a token signed by a kid not present in the trusted keystore, and a signature header of unexpected length, are both rejected without an unhandled exception reaching the caller.
  • Boundary - hostile signature header: POST a webhook with X-Signature set to a byte above 0x7F followed by 63 zeros, and again to 64 z characters. Both must produce the same status as a wrong-but-well-formed signature. A 500 on either means the header is still reaching compare_digest as a str, or bytes.fromhex is unguarded. Assert on the status code, not the body.
  • Malicious - cross-algorithm: re-sign a valid RS256 token as RS512 with the same key; jwt.decode() must raise InvalidAlgorithmError: The specified alg value is not allowed. This is the assertion that changes when the list is narrowed, so it is the one that proves the fix.
  • Malicious - algorithm confusion: re-sign a valid RS256 token as HS256 using the server's known RSA public key as the HMAC secret; jwt.decode() must raise. Run it with the key in the encoding your code actually holds. On PyJWT 2.14.0 and later both PEM and DER are refused by the library's own guard regardless of your algorithms list, so the test confirms the library and says nothing about the list; on 2.13.0 and earlier a DER key got through, and the test then exercised your configuration. Either way the cross-algorithm test above is the one that proves the fix.
  • Malicious - alg=none: submit a token with header {"alg":"none"} and an empty signature segment; decode() must reject it with InvalidAlgorithmError.
  • Malicious - tampered webhook payload: flip one byte in the raw request body while keeping the original signature header; verify_webhook_signature() must return False.
  • Malicious - forged asymmetric signature: call verify() with a signature that does not match the data; confirm the caller treats the resulting exception as a rejection, not a logged-and-ignored event.

Common Pitfalls

  • Setting algorithms=["RS256"] correctly but leaving a debug options={"verify_signature": False} reachable in production: these are sometimes added for local testing and gated behind a config flag that defaults incorrectly, or left in a code path a test exercises but production still imports. Search for verify_signature across the codebase, not just algorithms=.
  • Catching cryptography.exceptions.InvalidSignature alongside unrelated exceptions in the same except clause: a handler written as except (InvalidSignature, ValueError): that then logs and continues treats a legitimate signature-format error the same as a well-formed forged signature - both need to result in rejection, not silent continuation.
  • Fixing jwt.decode() calls but leaving a nearby jwt.get_unverified_claims() used for an authorization decision: like get_unverified_header(), this function explicitly does not check the signature; any code path that reads claims from it to grant access needs the same fix as a decode() call missing algorithms.

Dependencies and Installation

  • PyJWT (PyPI) - keep at a current maintained version; the algorithms requirement is enforced starting with PyJWT 2.x, and older 1.x releases had CVEs around alg: none handling. 2.14.0 extended the HMAC-secret guard to DER-encoded keys, closing the encoding gap described above.
  • cryptography (PyPI) - use for loading and verifying RSA/EC/Ed25519 keys directly; keep current for the latest algorithm support and security fixes.
  • hmac and hashlib are part of the Python standard library; no additional package is needed for compare_digest().

Migration Considerations

Narrowing algorithms to a single value will reject any previously accepted token signed with an algorithm being removed from the list (for example, a service that unintentionally accepted both RS256 and HS256 tokens). Confirm which algorithm your actual issuer uses in production before narrowing verification, and expect active sessions signed under a now-rejected algorithm to require re-authentication.

Additional Resources