Skip to main content
interlace
Plugin: jwtRules

no-hardcoded-secret

The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:

Disallow hardcoded secrets in JWT sign/verify operations

🚨 Security rule | 💡 Provides LLM-optimized guidance | ⚠️ Set to error in recommended

Quick Summary

AspectDetails
CWE ReferenceCWE-798
SeverityCritical
Auto-Fix❌ No auto-fix available
CategorySecurity
ESLint MCP✅ Optimized for ESLint MCP integration
Best ForProtecting JWT secrets from source code exposure

Error Message Format

The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:

🔒 CWE-798 OWASP:A04 CVSS:9.8 | Hardcoded Credentials detected | CRITICAL [SOC2,PCI-DSS,HIPAA,GDPR,ISO27001,NIST-CSF]
   Fix: Review and apply the recommended fix | https://owasp.org/Top10/A04_2021/

Message Components

ComponentPurposeExample
Risk StandardsSecurity benchmarksCWE-798 OWASP:A04 CVSS:9.8
Issue DescriptionSpecific vulnerabilityHardcoded Credentials detected
Severity & ComplianceImpact assessmentCRITICAL [SOC2,PCI-DSS,HIPAA,GDPR,ISO27001,NIST-CSF]
Fix InstructionActionable remediationFollow the remediation steps below
Technical TruthOfficial referenceOWASP Top 10

Rule Details

This rule detects hardcoded secrets in source code. Secrets committed to repositories can be extracted by anyone with code access.

Examples

❌ Incorrect

// Hardcoded string
jwt.sign(payload, 'my-secret-key');
jwt.verify(token, 'my-secret-key');

// Template literal
jwt.sign(payload, `static-secret`);

// A fallback that ships the literal the moment the env var is unset
jwt.sign(payload, process.env.JWT_SECRET || 'secret');
const SECRET = process.env.JWT_SECRET ?? 'changeme';

// jose: the byte key, inline or one const away, including SignJWT(...).sign(key)
const key = new TextEncoder().encode('secret');
await new SignJWT(claims).setProtectedHeader({ alg: 'HS256' }).sign(key);

// Config-object APIs
JwtModule.register({ secret: 'secretKey' }); // @nestjs/jwt
this.jwtService.sign(payload, { secret: 'secretKey' }); // @nestjs/jwt per call
expressjwt({ secret: 'shhhhhhared-secret', algorithms: ['HS256'] }); // express-jwt
new JwtStrategy({ secretOrKey: 'secret', jwtFromRequest }, verify); // passport-jwt
createSigner({ key: 'secret' }); // fast-jwt

✅ Correct

// Environment variable
jwt.sign(payload, process.env.JWT_SECRET);

// Config object
jwt.sign(payload, config.jwtSecret);

// Function call
jwt.sign(payload, getSecretFromVault());

// A PEM PUBLIC key or certificate is published on purpose — not a secret
const IDP_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----`;
jwt.verify(token, IDP_PUBLIC_KEY, { algorithms: ['ES256'] });

// Test files are skipped: a fixture token signed with 'test-secret' ships nothing

Calls that only share a method name are not JWT calls: a createSign() / createVerify() object from node:crypto, WebCrypto's crypto.subtle, and an injected this.<member> the class gives no evidence for. A member is a JWT client only when its type annotation resolves to a JWT library (private readonly jwt: JwtService), it is @Inject(X)-ed with a JWT import, or it is assigned from one (= new JwtService(), this.lib = jsonwebtoken). An untyped constructor(private readonly hashing) is not assumed to be one.

The key is followed within the file: a const, a let that is never reassigned, a destructure, a member of an object literal (config.jwt.secret), and the single return of a same-file function.

Known False Negatives

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

Dynamic Secret Construction

Why: The rule checks for literal strings but cannot evaluate runtime string operations.

// ❌ NOT DETECTED - String built at runtime
const prefix = 'secret';
const suffix = '-key-123';
jwt.sign(payload, prefix + suffix); // Concatenation not statically resolved

Mitigation: Use ESLint's prefer-template and code review. Consider runtime secret detection.

Encoded/Obfuscated Secrets

Why: The rule matches plain text literals, not decoded values.

// ❌ NOT DETECTED - Base64 encoded secret
const secret = Buffer.from('c2VjcmV0LWtleQ==', 'base64').toString();
jwt.sign(payload, secret);

Mitigation: Use pre-commit hooks that scan for base64-encoded secrets. Consider tools like git-secrets.

Cross-Module Secret Passing

Why: ESLint analyzes one file at a time; imported values cannot be traced.

// ❌ NOT DETECTED - Secret imported from another file
// secrets.ts: export const JWT_SECRET = 'hardcoded-secret';
import { JWT_SECRET } from './secrets';
jwt.sign(payload, JWT_SECRET); // Value unknown at lint time

Mitigation: Apply the rule to all files. Use secret scanning tools across the entire repository.

Object Property Secrets

Why: When identifier references are treated as safe (could be config), deeply nested literals are missed.

// ❌ NOT DETECTED - Nested in object created elsewhere
const config = createConfig(); // Returns { secret: 'hardcoded' }
jwt.sign(payload, config.secret); // Member expression treated as safe

Mitigation: Keep secrets in environment variables or a secret manager, and read them where the key is used.

Reassignable Bindings

Why: the key is read through a same-file const (including const SECRET = process.env.JWT_SECRET || 'secret'), but a let / var can be reassigned, so its initial value proves nothing.

// ⚠️ NOT DETECTED - a let can be reassigned
let key = 'my-secret-key';
jwt.sign(payload, key);

Mitigation: Use const bindings and avoid variable reassignment for secrets.

Further Reading

⚙️ Options

OptionTypeDefaultDescription
envPatternsstring[][]No effect — accepted so existing configs stay valid; the rule does not read it yet.
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.

Use this rule

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