Fairy
Resources

How to Avoid: JWT Signed Without Expiration

July 21, 2026 · 8-minute read · Fairy

The short answer

To prevent JWT signed without expiration in AI-generated code, always include the expiresIn option when calling jwt.sign() and configure your verifier to reject tokens missing the exp claim. Without expiration, a leaked token grants permanent access. Set short-lived access tokens (15 minutes to 1 hour) and use refresh tokens for longer sessions.

The Direct Answer: Always Set expiresIn When Signing JWTs

To prevent JWT signed without expiration in AI-generated code, you must explicitly include the expiresIn option in every jwt.sign() call and configure your token verifier to enforce expiration. This is a critical authentication vulnerability: tokens without expiration never expire, meaning a single leaked token grants permanent access to your system.

Here's the correct pattern:

// ❌ WRONG: AI often generates this
const token = jwt.sign({ userId: user.id }, SECRET);

// ✅ CORRECT: Always include expiration
const token = jwt.sign(
  { userId: user.id },
  SECRET,
  { expiresIn: '15m' }  // Short-lived access token
);

The verifier must also enforce expiration—don't just trust the client:

// Verification automatically rejects expired tokens
// but you should also handle the specific error
try {
  const decoded = jwt.verify(token, SECRET);
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    // Token existed but expired - prompt re-auth
  }
  // Handle other errors (invalid signature, malformed, etc.)
}

Why AI Generates JWTs Without Expiration

AI code generation models learn from vast repositories of code, tutorials, and documentation. The problem is that educational content often strips security features for brevity. A tutorial explaining JWT basics might show:

// From a typical tutorial - works but insecure
const token = jwt.sign(payload, secret);

This is technically correct—it produces a valid, signed token. But "working" and "production-ready" are different standards. AI models optimize for code that runs, not code that's secure by default.

This pattern appears frequently in Fairy's code reviews. When AI generates authentication flows, it consistently produces the minimal viable implementation. The expiresIn parameter is optional in the jsonwebtoken library, so the model has no syntax error to learn from. The code works, passes basic tests, and the vulnerability remains invisible until a security audit or breach.

The same dynamic creates related authentication gaps. In our review corpus, we see AI-generated code with protected routes missing auth checks, passwords stored without proper KDFs, and OAuth flows that skip state validation. The AI isn't malicious—it's just completing the pattern with the shortest path to working code.

The Security Impact of Forever-Valid Tokens

When a JWT has no expiration, your security model fundamentally breaks. Consider what happens when tokens leak:

Leaked through logs: Application logs often capture headers or request bodies during debugging. A token without expiration in your logs from two years ago? Still valid.

Stolen from browser storage: If you store tokens in localStorage (which AI often suggests), any XSS vulnerability exposes them. With expiration, the damage window is limited. Without it, attackers have permanent access.

Exposed in a data breach: Database backups, third-party integrations, or compromised services might expose tokens. Expired tokens in a breach are worthless. Permanent tokens are gold.

Shared accidentally: Developers paste tokens into Slack, commit them to repos, or share them in debugging sessions. Short expiration limits the blast radius.

The only remediation for leaked forever-tokens is rotating your signing secret—which invalidates every token you've ever issued, forcing all users to re-authenticate simultaneously. This is operationally painful and often triggers its own incidents.

How to Detect This Vulnerability

Static Analysis: Grep Isn't Enough

A simple search for jwt.sign without expiresIn catches obvious cases:

# Basic detection - finds some issues
grep -r "jwt.sign" --include="*.js" | grep -v "expiresIn"

But this misses common patterns:

// Expiration set in a variable - grep misses this
const options = getTokenOptions(); // might or might not include expiresIn
const token = jwt.sign(payload, secret, options);

// Expiration set via exp claim directly - technically works
const token = jwt.sign({ userId: 1, exp: Math.floor(Date.now() / 1000) + 3600 }, secret);

Proper detection requires understanding the code flow, which is why this is a common failure mode in AI code review workflows.

Verification-Side Checks

Even if you sign with expiration, verify that your consumers enforce it:

// ❌ DANGEROUS: Disabling expiration check
const decoded = jwt.verify(token, SECRET, { ignoreExpiration: true });

// ❌ DANGEROUS: Decoding without verification
const decoded = jwt.decode(token); // Never validates anything!

// ✅ CORRECT: Standard verification enforces expiration
const decoded = jwt.verify(token, SECRET);

Search your codebase for ignoreExpiration and any use of jwt.decode (which should only be used for inspecting tokens you've already verified, never for authentication).

Runtime Detection

Add monitoring for tokens with suspicious lifetimes:

// Middleware to detect long-lived tokens
function detectSuspiciousTokens(req, res, next) {
  const token = extractToken(req);
  if (token) {
    const decoded = jwt.decode(token);
    if (decoded && !decoded.exp) {
      logger.warn('Token without expiration detected', { 
        userId: decoded.userId,
        issuedAt: decoded.iat 
      });
    }
  }
  next();
}

The Correct Pattern: Short Access Tokens + Refresh Tokens

Production authentication systems use a two-token pattern that balances security with user experience:

// Token generation
function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }  // Short-lived
  );
  
  const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    REFRESH_SECRET,
    { expiresIn: '7d' }  // Longer-lived, stored securely
  );
  
  return { accessToken, refreshToken };
}

// Refresh endpoint
async function refreshAccessToken(refreshToken) {
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
    
    // Check if refresh token was revoked
    const user = await db.users.findById(decoded.userId);
    if (user.tokenVersion !== decoded.tokenVersion) {
      throw new Error('Refresh token revoked');
    }
    
    // Issue new access token
    return jwt.sign(
      { userId: user.id, role: user.role },
      ACCESS_SECRET,
      { expiresIn: '15m' }
    );
  } catch (err) {
    throw new Error('Invalid refresh token');
  }
}

Key elements of this pattern:

  1. Access tokens expire quickly (15 minutes). Even if leaked, the window is small.
  2. Refresh tokens live longer but are stored in httpOnly cookies, not localStorage.
  3. Token versioning allows instant revocation by incrementing user.tokenVersion.
  4. Separate secrets for access and refresh tokens limit blast radius if one is compromised.

Expiration Values for Different Use Cases

Not all tokens should have the same lifetime:

Use CaseRecommended Expiration
Access tokens (API calls)15 minutes – 1 hour
Refresh tokens7 – 30 days
Password reset links15 – 60 minutes
Email verification24 – 72 hours
"Remember me" sessions30 – 90 days
Machine-to-machine tokensHours to days, with rotation

AI-generated code often uses a single token type for everything. Splitting by use case limits the damage from any single compromise.

What Happens When You Fix Existing Code

If you discover your production system has been issuing tokens without expiration, you have options:

Option 1: Rotate the signing secret All existing tokens immediately become invalid. Users must re-authenticate. Simple but disruptive.

Option 2: Add expiration validation with a grace period Modify your verifier to reject tokens issued before a cutoff date that lack expiration:

function verifyToken(token) {
  const decoded = jwt.verify(token, SECRET);
  
  // Tokens issued before the fix date must have expiration
  const FIX_DATE = new Date('2024-01-15').getTime() / 1000;
  if (decoded.iat < FIX_DATE && !decoded.exp) {
    throw new Error('Legacy token without expiration - please re-authenticate');
  }
  
  return decoded;
}

Option 3: Gradual migration Issue new tokens with expiration on every request. Old tokens naturally phase out as users interact with the system. Set a deadline after which all non-expiring tokens are rejected.

Beyond Expiration: Complete JWT Security

Expiration is critical but not sufficient. AI-generated authentication code often has compounding issues. When reviewing JWT implementations, also verify:

These issues cluster together because AI generates code that works in development but lacks production hardening.

Integrating Detection Into Your Workflow

Manual review catches some JWT issues, but the pattern is subtle enough that it slips through. Effective detection requires:

  1. Pre-commit hooks that flag jwt.sign calls missing expiration parameters
  2. PR review automation that understands authentication flows, not just syntax
  3. Security-focused code review for any authentication-related changes

Fairy's code verification specifically checks for this pattern and related authentication gaps before AI-generated code reaches production. The platform's expert reviewers understand that working authentication code and secure authentication code aren't the same thing.

For a quick check on your existing code, Fairy Scout provides free AI PR review that catches common security patterns including JWT misconfiguration.

Summary: The Non-Negotiable Checklist

Before shipping any JWT implementation—AI-generated or otherwise:

JWT signed without expiration is one of the most common critical vulnerabilities in AI-generated authentication code. The fix is simple—always set expiresIn—but catching it requires vigilance because the insecure version works perfectly in testing.

Frequently asked questions

Why does AI-generated code often miss JWT expiration?

AI models optimize for working code, not secure code. The simplest jwt.sign() call that produces a valid token omits optional parameters like expiresIn. Training data includes many tutorials and examples that skip expiration for brevity.

What happens if a JWT has no expiration?

The token remains valid forever. If leaked through logs, browser storage, or a data breach, attackers can use it indefinitely. There's no automatic revocation—you'd need to rotate your signing secret, invalidating all tokens.

How long should JWT access tokens live?

Access tokens should expire in 15 minutes to 1 hour for most applications. Use short-lived access tokens paired with longer-lived refresh tokens (days to weeks) stored securely. Critical operations may warrant even shorter windows.

Can I add expiration after the JWT is already signed?

No. The exp claim is part of the signed payload. You must set expiration at signing time. Tokens already issued without expiration cannot be fixed—you need to reissue them or rotate your signing secret to invalidate all existing tokens.


Have AI-generated work you’d want verified? Connect with a Fairy → or run a free check with Scout.

More resources