#34985·backstage

auth: OAuth token response hard-codes expires_in: 3600, ignoring auth.backstageTokenExpiration

Author: timo-reymannCreated Jul 28, 2026Updated Sep 17, 2026
Labelsgood first issuepriority:contrib-neededarea:auth

Issue Labels

  • Please familiarize yourself with the issue labels used in this project

Search Terms

expires_in 3600
backstageTokenExpiration
OidcService expiresIn
oauth token endpoint expires_in hardcoded
access token expiry mismatch exp
mcp re-authentication token expired

No existing issue covers this. The nearest neighbours I found are #32645 (MCP auth, but about invalid client_id during dynamic client registration) and #32115 (closed; token verification failing after uptime due to JWKS key rotation) — both are distinct from the expires_in value itself being wrong.

️ Project Area

Auth

External Integration

N/A

Description & Context

The OAuth 2.0 token endpoint reports a fixed expires_in: 3600 regardless of the configured auth.backstageTokenExpiration, while the access token JWT it returns is stamped with an exp derived from that configuration. When the option is set to anything other than one hour, the token response contradicts the token it is describing.

auth.backstageTokenExpiration is a documented, publicly schema'd option (it appears in plugins/auth-backend/config.schema.json) and readBackstageTokenExpiration accepts anything from 10 minutes up to 24 hours:

typescript
// plugins/auth-backend/src/service/readTokenExpiration.ts
const TOKEN_EXP_DEFAULT_S = 3600;
const TOKEN_EXP_MIN_S = 600;
const TOKEN_EXP_MAX_S = 86400;

That value flows into the token issuer, so it determines the JWT's exp:

typescript
// plugins/auth-backend/src/service/router.ts
const backstageTokenExpiration = readBackstageTokenExpiration(config);
...
tokenIssuer = new TokenFactory({
  keyDurationSeconds: backstageTokenExpiration,
  ...
});

But OidcService hard-codes the advertised lifetime at both response sites, and neither consults the config even though the service already holds a Config instance:

typescript
// plugins/auth-backend/src/service/OidcService.ts:651  (authorization_code exchange)
return {
  accessToken: token,
  tokenType: 'Bearer',
  expiresIn: 3600,          // <-- ignores auth.backstageTokenExpiration
  idToken: token,
  scope: session.scope || 'openid',
  refreshToken,
};

// plugins/auth-backend/src/service/OidcService.ts:681  (refresh_token grant)
return {
  accessToken,
  tokenType: 'Bearer',
  expiresIn: 3600,          // <-- same
  refreshToken,
};

Both are surfaced verbatim as expires_in by OidcRouter.

Per RFC 6749 §5.1, expires_in is "the lifetime in seconds of the access token". Conforming clients cache the token for that long and then stop using it, so a token configured to live for, say, 24 hours is discarded after one hour — the remaining 23 hours are unusable.

The impact is worse for clients that cannot silently recover, which is the case for the MCP integration by default. OidcService only issues a refresh token when the granted scope contains offline_access:

typescript
const scopes = session.scope?.split(' ') ?? [];
if (scopes.includes('offline_access') && this.offlineAccess) { ... }

and the protected-resource metadata served for the MCP endpoint advertises no scopes_supported:

jsonc
// GET /.well-known/oauth-protected-resource/api/mcp-actions/v1
{ "resource": "...", "authorization_servers": ["..."] }

so clients following RFC 9728 have nothing telling them to request offline_access and typically don't. The result is a client that believes its token died after an hour, has no refresh token to use, and falls back to a full interactive browser authorization — repeatedly, for the entire configured lifetime of a token that was never actually expired. (Happy to file the missing scopes_supported as a separate issue if you'd prefer to track it independently; it looks like a distinct gap in plugin-mcp-actions-backend, still present in 0.2.0.)

Affected versions: reproduced on @backstage/plugin-auth-backend 0.29.0 and 0.29.2 (current latest at time of writing). Both expires_in sites are unchanged between them.

Expected Behavior

expires_in should reflect the actual lifetime of the returned access token, i.e. be derived from the same readBackstageTokenExpiration(config) value that the token issuer uses to compute the JWT's exp, so that the two cannot disagree.

Concretely, with auth.backstageTokenExpiration: { hours: 12 }, a token response should report expires_in: 43200 and the JWT exp should be ~12 hours out — rather than expires_in: 3600 alongside a 12-hour exp.

More generally: whatever value is advertised should be the value at which the token genuinely stops being accepted. Deriving it from a single source instead of a literal would prevent this class of drift.

Reproduction Repo

No response

Reproduction steps

  1. Create a fresh Backstage app and enable the OAuth authorization-server surface, so the token endpoint is reachable:

    yaml
    auth:
      backstageTokenExpiration: { hours: 12 }   # any value != 1 hour
      experimentalDynamicClientRegistration:
        enabled: true
        allowedRedirectUriPatterns:
          - 'http://localhost:*'
  2. Register a client at POST /api/auth/v1/register, then complete an authorization-code flow against GET /api/auth/v1/authorizePOST /api/auth/v1/token.

  3. Observe the token response advertises one hour:

    json
    { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "scope": "openid" }
  4. Decode the returned access_token and compare its claims — exp - iat is 43200 (12 hours), not 3600. The advertised lifetime is 1/12th of the real one.

  5. A conforming client now re-authorizes after one hour even though the token remains valid for another eleven.

Shortcut for steps 1–4 (no HTTP flow needed) — the same contradiction is visible directly, since the hard-coded literal is independent of any config:

grep -n "expiresIn: 3600" node_modules/@backstage/plugin-auth-backend/dist/service/OidcService.cjs.js

Have you read the Code of Conduct?

  • I have read the Code of Conduct

Are you willing to submit PR?

No, but I'm happy to collaborate on a PR with someone else