The sign-in that never checks the signature
A JSON Web Token has three parts: a header, a set of claims and a signature. The claims are what the application wants to know (who the caller is, what roles they hold, when the token expires). The signature is the only part that makes those claims worth believing. Without it, a token is a base64 string anyone can write by hand.
Most JWT libraries ship two functions that look alike. One verifies the signature and returns the claims. The other skips the check and returns the claims anyway, because there are honest reasons to peek inside a token: reading the kid header to choose a key, or showing an expiry time in a UI. When the second function ends up in the authentication middleware, the application still has a sign-in screen, still issues tokens and still rejects requests without one. It just accepts any token at all.
Two functions that look alike
In Node, the popular jsonwebtoken package has jwt.verify() and jwt.decode(). Its README is blunt about the second: it "will not verify whether the signature is valid", and should not be used for untrusted messages. Here is the shape we see when that warning is missed:
// Vulnerable: claims are trusted, signature is never checked
const jwt = require('jsonwebtoken');
function requireUser(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
const claims = jwt.decode(token);
if (!claims) return res.sendStatus(401);
req.user = { id: claims.sub, roles: claims.roles };
next();
}
app.get('/api/admin/users', requireUser, requireRole('admin'), listUsers);
Everything downstream looks correct. requireRole('admin') checks req.user.roles. But req.user came from a payload the caller wrote. A token with {"sub": "1", "roles": ["admin"]} and any signature, or none, walks through.
The fix is to verify, and to say exactly what a valid token looks like:
// Fixed: verify signature, algorithm, audience and issuer
function requireUser(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
try {
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ['RS256'],
audience: 'my-api',
issuer: 'https://idp.example.com/',
});
req.user = { id: claims.sub, roles: claims.roles };
next();
} catch {
res.sendStatus(401);
}
}
Python has the same trap with different spelling. PyJWT's jwt.decode() verifies by default, but accepts options={"verify_signature": False}. The PyJWT documentation calls this "generally ill-advised" unless you clearly understand what you are doing, and notes that without the signature the claims cannot be trusted.
# Vulnerable: FastAPI dependency that reads claims without verifying them
def current_user(authorization: str = Header(...)):
token = authorization.removeprefix("Bearer ")
claims = jwt.decode(token, options={"verify_signature": False})
return db.get_user(claims["sub"])
# Fixed: key, pinned algorithm, audience and issuer
def current_user(authorization: str = Header(...)):
token = authorization.removeprefix("Bearer ")
claims = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"],
audience="my-api",
issuer="https://idp.example.com/",
)
return db.get_user(claims["sub"])
python-jose's jwt.get_unverified_claims() and a hand-rolled base64.urlsafe_b64decode(token.split(".")[1]) do the same thing as the vulnerable version, with less ceremony.
How it ends up in production
Nobody writes "accept any token" on purpose. The usual stories are ordinary:
- A gateway or identity-aware proxy verified tokens, so the service behind it only needed to read them. Later the service gained a second route in that skipped the gateway.
- The decode was written for a quick prototype and the verify step was planned for later.
- The developer needed the
kidheader to pick a key, decoded the token to get it, and never called the verifying function afterwards. - A verifying call sits in a
tryblock whosecatchis empty, so a failed verification falls through to the code that trusts the claims.
None of these leave an obvious mark in a diff. The middleware still has "auth" in its name, and tests that send a valid token still pass.
The history: the attacker picks the algorithm
Unverified decoding is the bluntest version of a problem that has followed JWTs since the start: the token tells the verifier how to verify it. Tim McLean's write-up on the Auth0 blog described two attacks that shaped every library since. Some libraries accepted tokens whose header said "alg": "none" as validly signed. Others, expecting an RSA signature, would accept an HMAC one and use the server's public key as the HMAC secret, which anyone can do since the key is public.
The advisories followed. CVE-2015-9235 is the algorithm-confusion bypass in jsonwebtoken before 4.2.2, rated Critical. Years later, CVE-2022-23540 covered jsonwebtoken before 9.0.0: when jwt.verify() was called with no algorithm list and a falsy key, it fell back to none, so an unsigned token passed verification. The 9.0.0 release removed that default.
The pattern is still being fixed in application code. In 2026 the GitHub advisory CVE-2026-59163, rated Critical, described a Python sync server whose authentication decoded bearer tokens without verifying their HMAC signatures. Per the advisory, any well-formed token was accepted, including alg: none, so an attacker could forge a token for any user and read or change that user's data. The fix replaced the decode with a verifier that pins HS256 and checks expiry.
The standards body wrote down the lesson. RFC 8725, the JSON Web Token Best Current Practices (BCP 225, published in 2020), lists none and algorithm substitution under weak signatures and insufficient validation, and requires that libraries "MUST enable the caller to specify a supported set of algorithms." In practice that means three rules for application code:
- Always call the verifying function, with a key, before reading claims you will act on.
- Pass an explicit algorithm allowlist. Never accept
noneunless the token is protected some other way and you have decided that on purpose. - Check
exp,audandiss. A correctly signed token for a different audience is still the wrong token.
Why this is hard to see in a findings list
A lot of scanners already flag jwt.decode( as a line, or verify_signature: False as a literal. That is a useful start, and it is also noisy: plenty of code decodes a token to log it, to show an expiry date, or to read the kid before verifying. If every decode is a finding, the one in the middleware is buried among the harmless ones.
The question that matters is not "is there a decode" but "does an identity decision rest on it, and what does that decision protect". A decode in the authentication middleware of forty routes is a different finding from a decode in a debug page.
What Vulkro reports
Vulkro 0.34.0 added the forgeable-token-auth rule for JavaScript, TypeScript and Python. It looks for code that reads a token's claims without a signature check: jwt.decode(), jwtDecode(), jose.decodeJwt(), a base64 decode of the payload segment, PyJWT with verify_signature: False, and python-jose's get_unverified_claims(). It reports only when all three of these hold:
- the token comes from the request (a header, cookie, query value or a parameter the caller hands in),
- the decoded claims make an identity or authorization decision in the same function (installed as the caller, compared against a role, used to look the user up, or gating
next()), - no verification happens on that path, and no verifying guard sits in front of the endpoints the code serves. A verify whose error is swallowed by an empty
catchdoes not count as verification.
Decoding for display or logging does not fire.
When the decode sits in the authentication middleware of one or more endpoints, the finding is Critical and the message gives the number of endpoints it guards, because every one of them is an authentication bypass. Otherwise it is High. The proof is three hops: token read from the request, decoded without a signature check, claims trusted as identity. vulkro prove prints that chain for the finding.
The rule also changes the route inventory. A decode-only middleware is recorded as a guard that does not authenticate, so the endpoints behind it read as unauthenticated rather than signed in. That matters beyond this one finding: severity in Vulkro comes from who can reach the code, so other findings on those routes are now ranked as reachable by anyone, which is what they are.
Two related cases are worth knowing:
- An identity header set by an identity-aware proxy (for example Cloudflare Access, Google IAP or an AWS load balancer's OIDC headers) trusted without verifying the proxy's signed assertion is reported too, at High at most, because forging it needs a path to the origin that skips the proxy.
- Go and Java have their own rules for the same shape,
go-jwt-parse-unverified-authzandjava-jwt-decode-unverified, and the literal patterns (alg: 'none', a hardcoded signing secret) keep their own findings.
If a gateway really does verify every token before the service, say so with an inline // vulkro:disable forgeable-token-auth comment and a reason, and make sure the service cannot be reached any other way.
Where to see it
Run vulkro scan on a repository with a Node or Python API and look for forgeable-token-auth. vulkro explain forgeable-token-auth prints the full rule with fixes, and the broken authentication rule page covers the wider family. For the access-control side of the same problem, see broken access control is still number one. To get set up, start here.
Sources
- auth0/node-jsonwebtoken, README
- PyJWT, Usage examples
- Tim McLean, Critical vulnerabilities in JSON Web Token libraries, Auth0 blog
- GitHub Advisory Database, CVE-2015-9235: Verification Bypass in jsonwebtoken
- GitHub Advisory Database, CVE-2022-23540: jsonwebtoken insecure default algorithm
- GitHub Advisory Database, CVE-2026-59163: JWT signature verification bypass in a sync server
- IETF, RFC 8725: JSON Web Token Best Current Practices
