# PYTHON-JWT-SEC-003: Unverified JWT Decode

> **Severity:** HIGH | **CWE:** CWE-287, CWE-345 | **OWASP:** A07:2021

- **Language:** Python
- **Category:** JWT
- **URL:** https://codepathfinder.dev/registry/python/jwt/PYTHON-JWT-SEC-003
- **Detection:** `pathfinder scan --ruleset python/PYTHON-JWT-SEC-003 --project .`

## Description

This rule detects jwt.decode() calls where signature verification might be disabled.
The dangerous pattern is passing options={"verify_signature": False} to jwt.decode(),
which tells PyJWT to skip signature checking entirely. The result is the same as
using algorithm="none" -- any token, forged or modified, will be accepted.

This pattern appears more often than you'd expect. Developers disable verification
during debugging ("I just want to read the claims"), forget to re-enable it, and ship
it to production. Or they disable it because they're "verifying the token elsewhere"
-- but that elsewhere doesn't exist or has its own bugs.

The rule currently operates at audit level, flagging all jwt.decode() calls for review.
This is because the insecure configuration is a nested dict value
(options={"verify_signature": False}) that the engine can't precisely match yet.
Future engine updates will add nested keyword matching to flag only the unsafe
configuration.


## Vulnerable Code

```python
import jwt

# Vulnerable: decode with verify_signature=False bypasses integrity checks
data = jwt.decode(token, "secret", options={"verify_signature": False})

# Vulnerable: using options dict variable
opts = {"verify_signature": False, "verify_exp": False}
payload = jwt.decode(token, key, options=opts)
```

## Secure Code

```python
import jwt
import os

SECRET = os.environ["JWT_SECRET_KEY"]

# SECURE: Verify signature and specify allowed algorithms
payload = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    options={"require": ["exp", "iat"]}
)

# SECURE: RS256 verification with public key
with open("public_key.pem", "rb") as f:
    public_key = f.read()
payload = jwt.decode(token, public_key, algorithms=["RS256"])

```

## Detection Rule (Python SDK)

```python
from rules.python_decorators import python_rule
from codepathfinder import calls, flows, QueryType
from codepathfinder.presets import PropagationPresets

class JWTModule(QueryType):
    fqns = ["jwt"]


@python_rule(
    id="PYTHON-JWT-SEC-003",
    name="Unverified JWT Decode",
    severity="HIGH",
    category="jwt",
    cwe="CWE-287",
    tags="python,jwt,unverified,authentication,OWASP-A07,CWE-287",
    message="jwt.decode() with verify_signature=False. Token integrity is not checked.",
    owasp="A07:2021",
)
def detect_jwt_unverified_decode():
    """Detects jwt.decode() calls that may disable verification."""
    return JWTModule.method("decode")
```

## How to Fix

- Never set verify_signature to False in production -- if you need to read claims without verification, use jwt.decode(token, options={"verify_signature": False}) only in debug/test code and gate it behind an environment check
- Always pass an explicit algorithms list to jwt.decode() to prevent algorithm confusion attacks
- Use options={"require": ["exp", "iat"]} to ensure tokens have required claims
- Consider wrapping jwt.decode() in a helper function that enforces verification, so individual call sites can't accidentally disable it
- In PyJWT 1.x, the parameter was verify=False -- if you're migrating from 1.x to 2.x, search for both patterns

## Security Implications

- **Token Tampering:** Without signature verification, an attacker can modify any field in the JWT
payload -- user ID, role, permissions, expiration -- and the application will
accept the modified token as valid. The signature exists specifically to prevent
this.

- **Identity Spoofing:** An attacker can decode their own low-privilege token, change the subject claim
to another user's ID, and submit it. Without signature verification, the
application can't tell the difference between a legitimate token and a forged one.

- **Session Hijacking:** If tokens aren't verified, an attacker doesn't even need to steal a valid token.
They can create one from scratch with any claims they want. This bypasses all
session management controls.

- **Expiration Bypass:** Disabling verify_signature often disables expiration checking too, depending on
the options dict. An attacker with an expired token can keep using it indefinitely.


## FAQ

**Q: When is it safe to use verify_signature=False?**

Almost never in production. The only legitimate use is reading claims from a token
you've already verified elsewhere -- for example, extracting the "sub" claim for
logging after the API gateway already verified the signature. Even then, it's safer
to pass the verified payload object rather than re-decoding the raw token.


**Q: Why does this rule flag all jwt.decode() calls, not just the unsafe ones?**

The insecure configuration is a nested dict value -- options={"verify_signature": False}.
The engine can match simple keyword arguments like algorithm="none", but can't yet
inspect values inside dict arguments. This is tracked as a product roadmap item.
For now, the rule flags all jwt.decode() calls so you can review each one.


**Q: What changed between PyJWT 1.x and 2.x for verification?**

In PyJWT 1.x, you disabled verification with jwt.decode(token, verify=False). In
PyJWT 2.x, it moved to jwt.decode(token, options={"verify_signature": False}). If
you're migrating a codebase, search for both patterns. Both are equally dangerous.


**Q: Does jwt.decode() verify the signature by default?**

Yes. In PyJWT 2.x, signature verification is enabled by default. You have to
explicitly disable it by passing options={"verify_signature": False}. This means
the vulnerability requires an intentional (though misguided) code change.


**Q: How do I run this rule in CI/CD?**

Run: pathfinder ci --ruleset python/jwt --project .
It outputs SARIF, JSON, or CSV. On GitHub, it posts inline review comments directly
on pull requests pointing to the exact lines. No dashboard needed.


**Q: What's the difference between this rule and SEC-002 (none algorithm)?**

SEC-002 catches unsigned tokens at creation time (jwt.encode with algorithm="none").
This rule catches disabled verification at decode time (jwt.decode with
verify_signature=False). Both result in the same outcome -- unverified tokens -- but
at different points in the token lifecycle. You need both rules for complete coverage.


## References

- [CWE-287: Improper Authentication](https://cwe.mitre.org/data/definitions/287.html)
- [RFC 8725: JSON Web Token Best Current Practices](https://tools.ietf.org/html/rfc8725)
- [PyJWT API Reference -- jwt.decode()](https://pyjwt.readthedocs.io/en/stable/api.html#jwt.decode)
- [OWASP Identification and Authentication Failures](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)
- [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
- [PyJWT 2.x Migration Guide](https://pyjwt.readthedocs.io/en/stable/changelog.html)

---

Source: https://codepathfinder.dev/registry/python/jwt/PYTHON-JWT-SEC-003
Code Pathfinder — Open source, type-aware SAST with cross-file dataflow analysis
