Skip to content
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 into
Vito0912:personalfrom
stefan-ernst:fix/api-key-no-expiry-jwt-default-leak
May 4, 2026
Merged

fix(api-key): emit long-lived JWTs for "no expiration" keys#137
Vito0912 merged 2 commits into
Vito0912:personalfrom
stefan-ernst:fix/api-key-no-expiry-jwt-default-leak

Conversation

@stefan-ernst

Copy link
Copy Markdown

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.

stefan-ernst and others added 2 commits May 4, 2026 11:54
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.
@Vito0912

Vito0912 commented May 4, 2026

Copy link
Copy Markdown
Owner

Thanks!

@Vito0912
Vito0912 merged commit a408d82 into Vito0912:personal May 4, 2026
2 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants