Summary
When creating an API key with the "No expiration" option, the key record is stored with expiresAt: null as expected, but the issued JWT still carries an exp claim inherited from the global JwtModule default (JWT_TOKEN_EXPIRES_IN). Since verifyJwt() uses jwtService.verifyAsync(), which enforces exp, the token stops working once that window elapses — despite the UI and the database both saying the key never expires.
Counter-intuitively, "No expiration" yields the shortest-lived token of all the available options.
Environment
- Docmost v0.95.0 (self-hosted, Docker)
- API keys are an EE feature; our instance has
JWT_TOKEN_EXPIRES_IN=30d set
Steps to reproduce
- Settings → API keys → Create API key
- Set Expiration to "No expiration"
- Decode the returned JWT payload
Actual behavior
Two keys created on the same instance minutes apart:
| Key |
UI option |
DB expiresAt |
JWT exp |
Effective lifetime |
ds_api_key |
No expiration |
null |
1790838781 |
30 days |
test-key |
Custom → 2099-01-31 |
2099-01-31T00:00:00 |
4073500799 |
26,450 days |
Decoded payload of the "No expiration" key (signature and ids trimmed):
{
"sub": "…",
"apiKeyId": "…",
"workspaceId": "…",
"type": "api_key",
"iat": 1788246781, // 2026-09-01 16:13:01
"exp": 1790838781, // 2026-10-01 16:13:01 → exactly 30 days
"iss": "Docmost"
}
GET/POST /api/api-keys confirms the record itself is unbounded:
{ "id": "…", "name": "ds_api_key", "expiresAt": null, "deletedAt": null }
Expected behavior
A key created with "No expiration" should keep working indefinitely — or, if an unbounded JWT is undesirable, the UI should state the real token lifetime instead of labelling it "No expiration".
Probable cause
Client — apps/client/src/ee/api-key/components/create-api-key-modal.tsx:47
const getExpirationDate = (): string | undefined => {
if (expirationOption === "never") {
return undefined; // no expiresAt is sent
}
...
Server — apps/server/src/core/auth/services/token.service.ts:117
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
With expiresIn undefined the ternary passes {}, so the token inherits the module-wide default rather than being signed without an exp.
apps/server/src/core/auth/token.module.ts:14
JwtModule.registerAsync({
...
expiresIn: environmentService.getJwtTokenExpiresIn() as StringValue,
getJwtTokenExpiresIn() defaults to '90d' (environment.service.ts:63), so upstream users would see a 90-day cap instead of 30 — the same bug, just a longer fuse.
Verification — token.service.ts:144
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
});
verifyAsync enforces exp, so the JWT is rejected before the expiresAt: null record is ever consulted.
Caveat: the API key service itself lives under ee/ on the server side and is not in this repository, so I could not confirm how it calls generateApiToken(). The chain above is inferred from the public code plus the observed 30-day token on an instance configured with JWT_TOKEN_EXPIRES_IN=30d. If the EE service passes its own expiresIn, the fix belongs there instead.
Suggested fix
Pass an explicit long lifetime when no expiry is chosen, so the "never" case does not silently fall back to the session-token default:
return this.jwtService.sign(payload, { expiresIn: expiresIn ?? '100y' });
Alternatively, keep the current behavior but rename the option and surface the resulting expiry date in the "key created" modal, so users are not told a key is permanent when it is not.
Impact
The failure surfaces only after the window elapses, by which point the 401 is hard to connect back to the key-creation choice. Anything driven by an API key — CI jobs, MCP integrations, scheduled exports — breaks silently a month (or three) after setup.
Workaround
Choose Custom and set a far-future date. That path does propagate the value into the JWT exp, as the table above shows.
Summary
When creating an API key with the "No expiration" option, the key record is stored with
expiresAt: nullas expected, but the issued JWT still carries anexpclaim inherited from the globalJwtModuledefault (JWT_TOKEN_EXPIRES_IN). SinceverifyJwt()usesjwtService.verifyAsync(), which enforcesexp, the token stops working once that window elapses — despite the UI and the database both saying the key never expires.Counter-intuitively, "No expiration" yields the shortest-lived token of all the available options.
Environment
JWT_TOKEN_EXPIRES_IN=30dsetSteps to reproduce
Actual behavior
Two keys created on the same instance minutes apart:
expiresAtexpds_api_keynull1790838781test-key2099-01-31T00:00:004073500799Decoded payload of the "No expiration" key (signature and ids trimmed):
{ "sub": "…", "apiKeyId": "…", "workspaceId": "…", "type": "api_key", "iat": 1788246781, // 2026-09-01 16:13:01 "exp": 1790838781, // 2026-10-01 16:13:01 → exactly 30 days "iss": "Docmost" }GET/POST /api/api-keysconfirms the record itself is unbounded:{ "id": "…", "name": "ds_api_key", "expiresAt": null, "deletedAt": null }Expected behavior
A key created with "No expiration" should keep working indefinitely — or, if an unbounded JWT is undesirable, the UI should state the real token lifetime instead of labelling it "No expiration".
Probable cause
Client —
apps/client/src/ee/api-key/components/create-api-key-modal.tsx:47Server —
apps/server/src/core/auth/services/token.service.ts:117With
expiresInundefined the ternary passes{}, so the token inherits the module-wide default rather than being signed without anexp.apps/server/src/core/auth/token.module.ts:14getJwtTokenExpiresIn()defaults to'90d'(environment.service.ts:63), so upstream users would see a 90-day cap instead of 30 — the same bug, just a longer fuse.Verification —
token.service.ts:144verifyAsyncenforcesexp, so the JWT is rejected before theexpiresAt: nullrecord is ever consulted.Suggested fix
Pass an explicit long lifetime when no expiry is chosen, so the "never" case does not silently fall back to the session-token default:
Alternatively, keep the current behavior but rename the option and surface the resulting expiry date in the "key created" modal, so users are not told a key is permanent when it is not.
Impact
The failure surfaces only after the window elapses, by which point the 401 is hard to connect back to the key-creation choice. Anything driven by an API key — CI jobs, MCP integrations, scheduled exports — breaks silently a month (or three) after setup.
Workaround
Choose Custom and set a far-future date. That path does propagate the value into the JWT
exp, as the table above shows.