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
| Component | Purpose | Example |
|---|---|---|
| Risk Standards | Security benchmarks | CWE-613 OWASP:A07 CVSS:5.4 |
| Issue Description | Specific vulnerability | Insufficient Session Expiration detected |
| Severity & Compliance | Impact assessment | MEDIUM |
| Fix Instruction | Actionable remediation | Follow the remediation steps below |
| Technical Truth | Official reference | OWASP 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
| Option | Type | Default | Description |
|---|---|---|---|
maxExpirationSeconds | integer | unset | Longest token lifetime allowed, in seconds. Unset enforces no limit. |
trustedSanitizers | string[] | [] | No effect — accepted so existing configs stay valid; the rule does not read it yet. |
trustedAnnotations | string[] | [] | No effect — accepted so existing configs stay valid; the rule does not read it yet. |
strictMode | boolean | false | No 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:
expiresInas a number of seconds (3600,60 * 60 * 24, or aconstholding one);expiresInas a string in themsformat jsonwebtoken accepts ('15m','7d','2 hours'). A bare numeric string is milliseconds there:'7200'is 7.2 seconds;- a payload
expwritten 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 expirationMitigation: 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 implementationMitigation: 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{ "jsPlugins": ["oxlint-plugin-jwt-security"], "rules": { "jwt-security/require-expiration": "error" }}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.