Skip to content

[BUG] API key created with "No expiration" produces a token that expires in JWT_TOKEN_EXPIRES_IN (30d default on our instance) #2465

Description

@DaeSoo

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

  1. Settings → API keys → Create API key
  2. Set Expiration to "No expiration"
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions