no-decode-without-verify
The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:
Disallow trusting decoded JWT payload without signature verification
Severity: 🟠 High
CWE: CWE-345
Error Message Format
The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:
🔒 CWE-345 OWASP:A08 CVSS:7.5 | Insufficient Verification of Data Authenticity detected | HIGH
Fix: Review and apply the recommended fix | https://owasp.org/Top10/A08_2021/Message Components
| Component | Purpose | Example |
|---|---|---|
| Risk Standards | Security benchmarks | CWE-345 OWASP:A08 CVSS:7.5 |
| Issue Description | Specific vulnerability | Insufficient Verification of Data Authenticity detected |
| Severity & Compliance | Impact assessment | HIGH |
| Fix Instruction | Actionable remediation | Follow the remediation steps below |
| Technical Truth | Official reference | OWASP Top 10 |
Rule Details
This rule detects usage of jwt.decode() or the jwt-decode library. Decoded JWTs can be tampered with by attackers since decode() never verifies the signature.
Examples
❌ Incorrect
// jwt.decode() returns unverified data
const payload = jwt.decode(token);
const userId = jwt.decode(token).sub;
// jwt-decode library
import jwtDecode from 'jwt-decode';
const claims = jwtDecode(token);✅ Correct
// jwt.verify() validates signature
const payload = jwt.verify(token, secret);
const { sub } = jwt.verify(token, secret, { algorithms: ['RS256'] });
// jose library with verification
import { jwtVerify } from 'jose';
const { payload } = await jwtVerify(token, key);
// Reading only time claims — the client-side refresh check
const { exp } = jwtDecode(token);
if (Date.now() >= exp * 1000) refresh();
// Reading the header's kid to pick the JWKS key, then verifying
const decoded = jwt.decode(token, { complete: true });
const key = await client.getSigningKey(decoded.header.kid);
jwt.verify(token, key.getPublicKey(), { algorithms: ['RS256'] });
// Not JWT decodes: a TextDecoder, jose's base64url codec
const decoder = new TextDecoder();
decoder.decode(bytes);
base64url.decode(process.env.JWT_SECRET_B64);
// Decoding a token this code fetched itself from the authorization server
const tokens = await (await fetch(tokenUrl, { method: 'POST', body })).json();
decodeJwt(tokens.id_token).sub;
const response = await axios.post('/oauth/token', form);
decodeJwt(response.data.access_token).exp;A token is exempt as a back-channel value only when it structurally
comes from an HTTP response this code requested: the body of a fetch()
response, an axios / got / ky / undici response, or an openid-client grant
(client.callback(), authorizationCodeGrant()). Its field or variable
NAME is never consulted — data.id_token from await request.json() is a
token the browser POSTed, and is reported. A parameter is exempt only when
every call site in the file passes such a value.
// ❌ reported: the incoming request's body is the front channel
export async function POST(request) {
const data = await request.json();
return decodeJwt(data.id_token).sub;
}header.kid is exempt by default: a forged kid only selects a key the
signature then fails against. header.alg and header.jku are not — choosing
those from the token is the attack. allowHeaderInspection: true exempts every
read of .header.
Options
| Option | Type | Default | Description |
|---|---|---|---|
allowHeaderInspection | boolean | false | Allow decode() whose result is only read at .header (any header field). header.kid alone is always allowed |
trustedSanitizers | string[] | [] | No effect — accepted so existing configs stay valid; the rule does not read it yet. |
trustedAnnotations | string[] | ["@decoded-header-only","@verified-separately"] | Extra JSDoc annotations to treat as safe markers |
strictMode | boolean | false | No effect — accepted so existing configs stay valid; the rule does not read it yet. |
{
"jwt/no-decode-without-verify": ["error", {
"allowHeaderInspection": false,
"trustedAnnotations": ["@decoded-header-only", "@verified-separately"]
}]
}When Not To Use It
Use @decoded-header-only annotation when inspecting token headers before verification routing.
Known False Negatives
The following patterns are not detected due to static analysis limitations:
Manual Base64 Decoding
Why: The rule tracks jwt.decode() and jwt-decode imports, not raw Base64 parsing.
// ❌ NOT DETECTED - Direct base64 decode of JWT payload
const [header, payload, signature] = token.split('.');
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString());
// ^^ Attacker bypasses decode detection entirelyMitigation: Code review for manual JWT parsing. Consider using no-restricted-syntax to ban base64 decode patterns.
Aliased Decode Function
Why: The rule matches specific function names; renamed imports are not tracked.
// ❌ NOT DETECTED - Aliased import
import { decode as parseToken } from 'jsonwebtoken';
const payload = parseToken(token); // 'parseToken' not recognizedMitigation: Use consistent import naming. Add custom matchers via configuration.
Dynamic Method Access
Why: Computed property access cannot be resolved statically.
// ❌ NOT DETECTED - Dynamic method call
const method = 'decode';
jwt[method](token); // Method name unknown at lint timeMitigation: Avoid dynamic property access on security-sensitive APIs.
Verified-Then-Decoded Pattern
Why: The rule cannot track control flow to determine if verify was called first.
// ❌ FALSE POSITIVE RISK - May flag despite prior verification
async function getPayload(token: string) {
await jwt.verify(token, secret); // Verification here...
return jwt.decode(token); // ...but decode flagged anyway
}Mitigation: Use @verified-separately annotation for legitimate patterns.
Further Reading
Use this rule
npm install -D oxlint-plugin-jwt-security{ "jsPlugins": ["oxlint-plugin-jwt-security"], "rules": { "jwt-security/no-decode-without-verify": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-jwt-security'], rules: { 'jwt-security/no-decode-without-verify': 'error', }, },});oxlint-plugin-jwt-security re-exports eslint-plugin-jwt-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.