Python FastAPI: JWT signature is never verified, so any token is accepted
- ID
- WM-2026-004
- Severity
- Critical CVSS 3.1 base 9.1
- Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N- Class
- Improper authentication · CWE-347 Improper Verification of Cryptographic Signature, CWE-287 Improper Authentication
- Component
- api.plankdesigns.co, Python 3.12 / FastAPI, get_current_user dependency
- Status
- Fixed, published after the fix
- Published
- 2026-10-04
Executive summary
The authentication dependency decodes the bearer token with signature verification switched off and then trusts the claims inside it, including the user id and role. An attacker can create a token with any contents, using any key, and be treated as any user, including an administrator.
Impact
- Full authentication bypass: impersonation of any user without knowing any secret.
- Access to administrative endpoints by claiming
role: admin, including data export. - All data exposed through the API is readable and writable as the impersonated user.
Technical details
root causeAuthentication is implemented as a FastAPI dependency used by every protected route. It decodes the JWT, but disables signature verification, probably left over from debugging:
# auth.py (vulnerable)
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer
bearer = HTTPBearer()
def get_current_user(cred = Depends(bearer)):
try:
claims = jwt.decode(cred.credentials, options={"verify_signature": False})
except jwt.PyJWTError:
raise HTTPException(401)
return User(id=claims["sub"], role=claims["role"]) # role taken from the tokenA JWT is only meaningful if its signature is checked. With verification disabled, the token is just attacker-supplied JSON, and the server then takes both the identity (sub) and the privilege (role) from it. The expiry is not checked either, so a forged token never expires.
Proof of concept
minimal, no real dataReproduced against a local copy of the API, using an arbitrary key that the server has never seen.
$ python3 -c 'import jwt; print(jwt.encode({"sub":"1","role":"admin"}, "any-key", algorithm="HS256"))'
<token>
$ curl -s https://api.plankdesigns.co/api/v1/admin/export -H "Authorization: Bearer <token>"
HTTP/1.1 200 OK # accepted; no valid credential was neededThe export response was not read; the status code alone confirmed the bypass.
Remediation
Verify the signature with the server's key, pin the allowed algorithm, require the expiry, and read the role from the database instead of the token.
# auth.py (fixed)
def get_current_user(cred = Depends(bearer), db = Depends(get_db)):
try:
claims = jwt.decode(
cred.credentials,
settings.JWT_SECRET, # from a secret store, not source code
algorithms=["HS256"], # pin; never accept "none"
audience="api.plankdesigns.co",
options={"require": ["exp", "sub", "aud"]},
)
except jwt.PyJWTError:
raise HTTPException(401)
user = db.get(User, claims["sub"]) # current role comes from the database
if user is None or user.disabled:
raise HTTPException(401)
return user- Rotate the signing secret and invalidate existing sessions, because forged tokens may already have been issued.
- Prefer asymmetric signing (for example RS256 or EdDSA) so services that verify tokens do not hold the signing key.
- Add a test that sends a token signed with the wrong key and expects 401.
- Search for other uses of
verify_signatureset to false, and for other places that trust claims without verification.
Detection
Alert on successful requests whose token was not issued by the identity service, for example by logging the token id (jti) and comparing it with issued tokens.
Disclosure timeline
days from report- d0
- Report sent to the vendor security contact
- d1
- Acknowledged
- d3
- Fix deployed and all existing sessions invalidated
- d10
- Retested; access log review completed
- d90
- Public disclosure window (published after the fix)