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:
- Access tokens expire quickly (15 minutes). Even if leaked, the window is small.
- Refresh tokens live longer but are stored in httpOnly cookies, not localStorage.
- Token versioning allows instant revocation by incrementing
user.tokenVersion. - 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 Case | Recommended Expiration |
|---|---|
| Access tokens (API calls) | 15 minutes – 1 hour |
| Refresh tokens | 7 – 30 days |
| Password reset links | 15 – 60 minutes |
| Email verification | 24 – 72 hours |
| "Remember me" sessions | 30 – 90 days |
| Machine-to-machine tokens | Hours 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:
- Signing algorithm is explicit: Always specify
{ algorithm: 'HS256' }or your chosen algorithm. Thealg: noneattack exploits implicit algorithm selection. - Secrets are actually secret: Check that
SECRETisn't hardcoded or committed to version control. - Token payload doesn't contain sensitive data: JWTs are signed, not encrypted. Don't put passwords, SSNs, or secrets in the payload.
- HTTPS is enforced: Tokens in transit over HTTP are trivially intercepted.
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:
- Pre-commit hooks that flag
jwt.signcalls missing expiration parameters - PR review automation that understands authentication flows, not just syntax
- 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:
- Every
jwt.sign()call includesexpiresIn - Access tokens expire in 1 hour or less
- Refresh tokens are stored in httpOnly cookies, not localStorage
- Verification uses
jwt.verify(), never justjwt.decode() -
ignoreExpiration: trueappears nowhere in your codebase - Token versioning or a revocation list enables emergency invalidation
- The signing secret is in environment variables, not code
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