Skip to main content
interlace
Plugin: jwtRules

no-sensitive-payload

JWT payloads are NOT encrypted, only base64-encoded

Prevent storing sensitive data in JWT payload which is only base64-encoded

Severity: 🟡 Medium
CWE: CWE-359

Rule Details

JWT payloads are NOT encrypted, only base64-encoded. Anyone can decode and read the payload contents. Sensitive data like passwords, PII, or financial information should never be stored in JWT payloads.

Detected Sensitive Fields

  • Passwords: password, passwd, pwd, secret
  • PII: email, phone, ssn, address, dob
  • Financial: creditCard, cardNumber, cvv, bankAccount
  • Tokens: accessToken, refreshToken, apiKey
  • Supports camelCase, snake_case, and kebab-case variants

Examples

❌ Incorrect

jwt.sign({ password: 'secret123' }, secret);
jwt.sign({ email: 'user@example.com' }, secret);
jwt.sign({ ssn: '123-45-6789' }, secret);
jwt.sign({ credit_card: '4111111111111111' }, secret);
jwt.sign({ sub: user.id, passwordHash: user.passwordHash }, secret);

// The payload one const away, a spread of a const, jose's constructor claims
const payload = { sub: user.id, password: user.password };
jwt.sign(payload, secret);
await new SignJWT({ sub: user.id, password: user.password })
  .setProtectedHeader({ alg: 'HS256' })
  .sign(key);

// A whole record spread into the claims (CWE-200): every field it has —
// the password hash included, and any column added later — goes in the token
function issue(user) {
  return jwt.sign({ ...user }, secret);
}
const row = await prisma.user.findUnique({ where: { id } });
jwt.sign({ ...row, role: 'user' }, secret);

A spread whose source cannot be followed to an object literal in the file is reported as a whole-record exposure, with a "pick the claims" fix. With type information (parserOptions.projectService) the declared fields of the spread source are known, and the spread is reported only when one of them is sensitive — { ...claims } of { sub; scope } is then left alone.

✅ Correct

// Store sensitive data server-side, reference by ID
jwt.sign({ sub: 'user-id-123', role: 'admin' }, secret);
jwt.sign({ userId: 'abc123', permissions: ['read'] }, secret);

// Pick the claims explicitly instead of spreading the record
jwt.sign({ sub: user.id, role: user.role }, secret);

Options

OptionTypeDefaultDescription
additionalSensitiveFieldsstring[][]Additional field names to flag as sensitive
trustedSanitizersstring[][]No effect — accepted so existing configs stay valid; the rule does not read it yet.
trustedAnnotationsstring[][]No effect — accepted so existing configs stay valid; the rule does not read it yet.
strictModebooleanfalseNo effect — accepted so existing configs stay valid; the rule does not read it yet.
{
  "jwt/no-sensitive-payload": ["error", {
    "additionalSensitiveFields": ["customSecret", "internalId"]
  }]
}

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Computed Property Names

Why: The rule checks literal property names; computed properties are not resolved.

// ❌ NOT DETECTED - Dynamic property name
const field = 'password';
jwt.sign({ [field]: 'secret123' }, secret); // Property name unknown

Mitigation: Avoid computed property names in JWT payloads. Use TypeScript interfaces.

A Whole Record Passed Without a Spread

Why: the whole-record check reads spreads. A record passed as the payload itself is not resolved to its fields.

// ⚠️ NOT DETECTED - the record is the payload, not a spread into it
function issue(user) {
  return jwt.sign(user, secret);
}

Mitigation: Build the payload as an object literal of the claims you mean to send.

Nested Sensitive Data

Why: The rule checks top-level properties by default.

// ❌ NOT DETECTED - Sensitive data in nested object
jwt.sign(
  {
    sub: '123',
    profile: { email: 'user@example.com' }, // Nested - not detected
  },
  secret,
);

Mitigation: Configure checkNestedProperties: true if available. Flatten sensitive checks in code review.

Obfuscated Field Names

Why: Field name patterns don't match intentionally obfuscated names.

// ❌ NOT DETECTED - Obfuscated field name
jwt.sign(
  {
    sub: '123',
    e: 'user@example.com', // 'e' for email - not in pattern
    p: '555-1234', // 'p' for phone - not in pattern
  },
  secret,
);

Mitigation: Use additionalSensitiveFields to add custom patterns.

Payloads Built Elsewhere

Why: a payload in a same-file const is read — const payload = { ssn }; jwt.sign(payload, secret) IS detected — but a payload from a parameter, an import or a call is not.

// ⚠️ NOT DETECTED - payload from a parameter
function issue(payload) {
  return jwt.sign(payload, secret);
}

Mitigation: Use inline objects. Apply TypeScript types that exclude sensitive fields.

Further Reading

Use this rule

npm install -D oxlint-plugin-jwt-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-jwt-security"],  "rules": {    "jwt-security/no-sensitive-payload": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-jwt-security'],    rules: {      'jwt-security/no-sensitive-payload': '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.