[BUG] Gateway provider discards the refresh token and has no refresh-token grant, so every renewal is a full device-code re-grant

Status Fixed / completed
Reported on v2.1.229
Maintainer reply None cached
Activity 1 comment · opened Aug 17, 2026 · closed Aug 25, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

In a self-hosted claude gateway deployment, Desktop never issues a grant_type=refresh_token request, though the gateway returns a refresh token on every grant. Its only path to a credential is a fresh device-code grant.

The token is dropped at parse. The response is {access_token, refresh_token, token_type, expires_in}; Desktop's schema has no such key:

{ access_token: …, expires_in: …, expires_at: …, error: … }.strip()

It is not small. Token responses are a constant 2,374 bytes while the access token measures ≈383 (authenticated GET /user/bootstrap 664 B vs 257 unauthenticated), leaving ≈1.6 KB that can only be the discarded refresh token.

Nothing downstream could use it. The grant returns {token, expiresAt} — no refresh field — and lTt = "urn:ietf:params:oauth:grant-type:device_code" is the module's only grant type. So a fix needs the schema field, somewhere to keep it, persistence, a refresh request, and the heal path preferring it. Every other provider in the bundle already has this (Bedrock SSO, Entra/Foundry, Vertex ADC, first-party OAuth, MCP, plugins), which suggests an unfinished path rather than a decision.

Why users see it. Renewal is checked only at turn start, with a two-minute margin against the expiry captured when the SDK child spawned — there is no timer:

maybeRefreshBeforeTurn(t) { return t != null && t - Date.now() <= 12e4 }

A turn that begins with more than two minutes left and outlives expiry therefore gets nothing, fails 401, and is not retried after the heal completes. Mid-turn the heal can only attempt another device-code grant, which works while the IdP session is silently satisfiable and otherwise surfaces "Sign in again to continue".

Rate. One machine, two weeks: 47 renewals, 11 rejections, clustered on long uninterrupted sessions. On one day at ttl_hours: 1: 83 × 401 on /v1/messages — against the terminal CLI's 129 refresh grants, all 200, zero 401s on the same gateway and lifetime. Worst case: expiry landed during automatic compaction, which then failed with 401 invalid token, leaving the session unable to self-recover.

---

What Should Happen?

  1. Support the refresh-token grant for the gateway provider — keep the token, persist it, spend it. The gateway advertises refresh_token in grant_types_supported and the terminal CLI already does exactly this against the same endpoint.
  2. Schedule renewal against the deadline, not only at turn start. Desktop already renders a "session expires in 10m" banner from expiresAt, so the deadline is known and used only to warn.
  3. Retry the request after a successful heal — when a 401 is followed seconds later by a completed grant, the condition has self-resolved.

Any one removes the user-visible failure; (1) also removes the recurring interactive-sign-in risk.

Error Messages/Logs

Client log, local time, redacted — rejections ~60 min apart on a 1-hour TTL:


12:26:09  [custom-3p] credential rejected by server { channel: 'sdk-refresh', healIsFutile: false }
12:26:10  [CCD CycleHealth] api_error: Prompt is too long · automatic compaction failed:
          Failed to authenticate. API Error: 401 invalid token
12:26:44  [CCD] Failed to warm session: Your session has expired. Sign in again to continue.
          { name: 'CredentialNeedsInteractiveError' }
12:26:46  [custom-3p] gateway SSO grant complete
13:26:48  [custom-3p] credential rejected by server { channel: 'sdk-refresh' }
13:26:58  [custom-3p] gateway SSO grant complete
15:28:07  [custom-3p] credential rejected by server { channel: 'sdk-refresh' }
15:28:29  (interactive sign-in required — no automatic grant followed)


The 30-minute bootstrap re-poll also fails on the dead credential rather than renewing — 63 of 65 occurrences log `periodic bootstrap re-poll failed; keeping last-good { kind: 'auth' }`.

Load-balancer log, same day: nine complete `device_authorization → callback → token` sequences, each token response 2,374 bytes, and **no refresh grant at any point**.

Steps to Reproduce

  1. Deploy a gateway whose upstream IdP issues refresh tokens, with a short lifetime so boundaries are observable:

``yaml
session: { jwt_secret: ${GATEWAY_JWT_SECRET}, ttl_hours: 1 }
``

  1. Point Desktop at it: inferenceProvider: gateway, inferenceGatewayBaseUrl: https://<host>, inferenceCredentialKind: interactive, bootstrapUrl: https://<host>/user/bootstrap, and leave inferenceGatewayOidc unset.
  2. Sign in, use the Code tab, and watch the gateway's token endpoint.

Deterministic: across any number of hours, only device_authorization → callback → token(device_code) sequences appear. No grant_type=refresh_token request is ever made, despite one being available in every token response.

Intermittent (the user-visible part): leave the app running and untouched across a boundary, then send a message as the first action after it. The request returns 401, the turn fails, and a new grant completes 10–45 s later without the request being retried.

Claude Model

Opus

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.229 — Desktop's embedded engine (app 1.30096.5, current release). Users do not select the engine version; latest standalone is 2.1.234. Gateway binary is 2.1.234; the terminal CLI, unaffected, is 2.1.220–2.1.232.

Platform

AWS Bedrock

Operating System

macOS

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗