Skip to main content
interlace
Plugin: jwtRules

require-expiration

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

Require expiration claim (exp) or expiresIn option in JWT signing

Severity: 🟡 Medium
CWE: CWE-613

Error Message Format

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

⚠️ CWE-613 OWASP:A07 CVSS:5.4 | Insufficient Session Expiration detected | MEDIUM
   Fix: Review and apply the recommended fix | https://owasp.org/Top10/A07_2021/

Message Components

ComponentPurposeExample
Risk StandardsSecurity benchmarksCWE-613 OWASP:A07 CVSS:5.4
Issue DescriptionSpecific vulnerabilityInsufficient Session Expiration detected
Severity & ComplianceImpact assessmentMEDIUM
Fix InstructionActionable remediationFollow the remediation steps below
Technical TruthOfficial referenceOWASP Top 10

Rule Details

This rule mandates expiration in JWT tokens. Tokens without expiration are valid forever, increasing the exposure window if compromised.

Examples

❌ Incorrect

NestJS: this.jwtService.sign(payload) when the package's JwtModule.register(...) / registerAsync(...) sets no signOptions.expiresIn. The rule walks up to the nearest package.json, reads every *.module.ts under it that registers JwtModule, and reports only when every registration it finds can be read. No registration found, or one it cannot read (a factory returning config.get('jwt')), means no report.

jwt.sign(payload, secret);
jwt.sign(payload, secret, {});
jwt.sign(payload, secret, { algorithm: 'RS256' });

✅ Correct

// expiresIn option
jwt.sign(payload, secret, { expiresIn: '1h' });

// exp in payload
jwt.sign({ sub: 'user', exp: Math.floor(Date.now() / 1000) + 3600 }, secret);

// options in a const, behind a cast
const signOptions = { algorithm: 'RS256', expiresIn: '15m' };
jwt.sign(payload, key, signOptions);
jwt.sign(payload, key, { expiresIn: process.env.JWT_EXPIRES_IN } as SignOptions);

// @nestjs/jwt: options are the second argument; a bare sign(payload) /
// signAsync(payload) takes expiresIn from JwtModule.register({ signOptions })
this.jwtService.sign(payload, { secret, expiresIn: '7d' });
this.jwtService.signAsync(payload); // JwtModule sets signOptions.expiresIn

// jose: expiry set on the builder, inline or in its own statement
const builder = new SignJWT(claims).setProtectedHeader({ alg: 'ES256' });
builder.setExpirationTime('1h');
await builder.sign(key);

Options

OptionTypeDefaultDescription
maxExpirationSecondsintegerunsetLongest token lifetime allowed, in seconds. Unset enforces no limit.
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/require-expiration": ["error", {
    "maxExpirationSeconds": 86400
  }]
}

maxExpirationSeconds

Opt-in. When set, a token whose lifetime the file states statically and that exceeds the limit is reported:

  • expiresIn as a number of seconds (3600, 60 * 60 * 24, or a const holding one);
  • expiresIn as a string in the ms format jsonwebtoken accepts ('15m', '7d', '2 hours'). A bare numeric string is milliseconds there: '7200' is 7.2 seconds;
  • a payload exp written as the current time plus a constant, Math.floor(Date.now() / 1000) + 3600.

A lifetime the file cannot see — process.env.JWT_TTL, a parameter, a call — is not judged, and neither is an absolute exp timestamp or a NestJS JwtModule.register({ signOptions }) default in another file.

// With { maxExpirationSeconds: 3600 }
jwt.sign(payload, secret, { expiresIn: '7d' }); // ❌ 604800s > 3600s
jwt.sign(payload, secret, { expiresIn: '15m' }); // ✅

Known False Negatives

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

Dynamic Payload Construction

Why: The rule checks literal payload objects; computed payloads are not analyzed.

// ❌ NOT DETECTED - Payload built dynamically
function buildPayload(userId: string) {
  return { sub: userId }; // No exp claim
}
jwt.sign(buildPayload('user123'), secret); // Missing expiration

Mitigation: Add expiration in the sign() options as a backup.

Spread Operator Payload

Why: Spread objects hide the actual claims at lint time.

// ❌ NOT DETECTED - exp might be missing in baseClaims
const baseClaims = getBaseClaims();
jwt.sign({ ...baseClaims, sub: userId }, secret);

Mitigation: Always include expiresIn in options, not just exp in payload.

Payloads and Options Built Elsewhere

Why: a payload or options object in a same-file const is read (const opts = { expiresIn: '1h' } satisfies the rule). Options from a parameter, an import or a call cannot be seen, so the rule stays silent on them; a payload from a parameter is still reported when no expiresIn is visible.

// ⚠️ NOT DETECTED - options built elsewhere may carry expiresIn
jwt.sign(payload, secret, getSignOptions());

Mitigation: Use inline objects with TypeScript interfaces that require exp.

Cross-File Payload Creation

Why: Payloads created in other modules are not visible.

// ❌ NOT DETECTED - Payload from imported function
import { createTokenPayload } from './tokens';
jwt.sign(createTokenPayload(user), secret); // Depends on implementation

Mitigation: Apply rule to all modules. Use TypeScript interfaces with required exp field.

Excessive Expiration Values

Why: Very large expiresIn values (e.g., '100y') pass the check but are effectively non-expiring, and no limit is enforced unless one is configured.

// ❌ NOT DETECTED (by default) - Effectively non-expiring
jwt.sign(payload, secret, { expiresIn: '100y' });

Mitigation: Set the maxExpirationSeconds option to enforce a limit.

Further Reading

Use this rule

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