This repository was archived by the owner on May 19, 2026. It is now read-only.
fix(api-key): emit long-lived JWTs for "no expiration" keys - #137
Merged
Vito0912 merged 2 commits intoMay 4, 2026
Merged
Conversation
API keys created via Settings → API management with the "no expiration"
option were silently issued JWTs that carried a 90-day exp claim. The DB
column correctly stored expires_at = NULL and ApiKeyService.validateApiKey
honored that, but the JWT verify layer (passport-jwt in JwtStrategy and
jwtService.verifyAsync in McpAuthGuard) enforces the standard exp claim
first. After 90 days the token starts failing every request even though
the DB still considers the key valid forever — and there is no refresh
endpoint, so users have to revoke + recreate.
Root cause: TokenService.generateApiToken was calling
jwtService.sign(payload, expiresIn ? { expiresIn } : {})
When expiresIn is undefined, the empty options object inherits the
module-level signOptions.expiresIn default ('90d', set in TokenModule for
short-lived access tokens). passing { expiresIn: undefined } would also
fail because jsonwebtoken@9 validates options strictly and rejects
undefined.
Fix: always pass an explicit expiresIn, falling back to '100y' for the
no-expiration case so the JWT exp matches the DB's "no expiry" semantics
in practice. The DB's apiKey.expiresAt remains the authoritative source
of truth.
Tests: replaced the smoke-only token.service.spec with two real
assertions that wire up a JwtModule mirroring production config and
decode the returned token. Locks in the regression — the no-expiry test
fails on the unfixed code with TTL = 7,776,000 (exactly 90 days).
Note: existing API keys already issued with the bug still have the bad
exp baked into their JWT signature and will expire 90 days from creation.
Affected users need to revoke and recreate their keys after upgrading.
Owner
|
Thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
hey @Vito0912, I came across this one when testing out the MCP PR. In case you want to merge this one.
I did not want to touch too many codeparts to fix this one properly. I believe a scoped token system would be better so we could have native no expiration tokens without the security risk.
API keys created via Settings → API management with the "no expiration" option were silently issued JWTs that carried a 90-day exp claim. The DB column correctly stored expires_at = NULL and ApiKeyService.validateApiKey honored that, but the JWT verify layer (passport-jwt in JwtStrategy and jwtService.verifyAsync in McpAuthGuard) enforces the standard exp claim first. After 90 days the token starts failing every request even though the DB still considers the key valid forever — and there is no refresh endpoint, so users have to revoke + recreate.
Root cause: TokenService.generateApiToken was calling
jwtService.sign(payload, expiresIn ? { expiresIn } : {})
When expiresIn is undefined, the empty options object inherits the module-level signOptions.expiresIn default ('90d', set in TokenModule for short-lived access tokens). passing { expiresIn: undefined } would also fail because jsonwebtoken@9 validates options strictly and rejects undefined.