#14449·cli

Refreshable (short-lived) OAuth token support

Author: babakksCreated Sep 15, 2026Updated Sep 15, 2026
Labelsenhancementgh-auth

Envelope issue for the refreshable-token stacked PRs. This issue explains the overall design, the decisions we made, and the challenges we hit. Each PR in the stack links back here and covers one slice of the work. Review the PRs bottom to top.

Summary

This work teaches gh to obtain, store, refresh, and consume short-lived OAuth credentials (an access token plus a rotating refresh token), instead of only the long-lived, non-expiring tokens it has used until now.

A short-lived credential comes as a pair: an access token that expires within hours, and a refresh token used to obtain a new one. When the access token runs out, gh presents the refresh token to the OAuth server's token endpoint and receives a fresh credential. That exchange also rotates the refresh token itself: the server issues a brand new refresh token and invalidates the previous one, so each refresh token is effectively single-use and only the most recently issued one remains valid. gh performs this transparently, so the user keeps working while the credential quietly rotates underneath.

A key constraint shapes the whole design: whether a login yields a short-lived or a permanent (non-expiring) credential is not something gh, or any OAuth client, can force. gh auth login --short-lived only asks for it, by adding the offline_access scope to the device flow; the actual outcome depends on the OAuth app's configuration and whether the server supports issuing refreshable tokens at all. So gh acts on the credential it receives, not the one it requested. If the server returns a refreshable credential, gh manages its refresh lifecycle; if it returns an ordinary non-expiring token, gh behaves exactly as before. This keeps the change backward compatible in both directions: users who never pass --short-lived see no difference, and users who do fall back cleanly when the server does not honor the request.

Motivation

Long-lived tokens are convenient but risky: a leaked token is valid until it is manually revoked. Short-lived tokens shrink that exposure window. The access token expires within hours, and gh transparently exchanges the refresh token for a new one when needed, so the user never sees the churn. This brings gh in line with the direction of GitHub's OAuth platform and lets security conscious users (and organizations) prefer credentials that rotate on their own.

What changes for users

  • New opt-in flag: gh auth login --short-lived.
  • When a short-lived credential is in use, gh refreshes it automatically inside ordinary commands (API calls, gh auth token, gh auth status, the git credential helper, and agent-task/CAPI requests).
  • gh auth status surfaces the expiry details of a short-lived token.
  • Using short-lived credentials for git operations through gh auth git-credential is recommended on git 2.46 or newer (see the git-caching decision below).
  • Users who never pass --short-lived see no behavioral change and take on no new requirement.

Design overview

The change is layered from the storage foundation up to each command that consumes a token.

  1. Credential model. A single gh.Credential type carries the token and, when present, its refresh metadata (refresh token, expiry timestamps). The auth storage interface is migrated to this type so every layer speaks one vocabulary.
  2. Storage. Refreshable credentials live only in the per-user slot (keyring service gh:_refreshable:<host> under the username, or hosts.<host>.users.<user>.<refreshable key>), never in the per-host active slot. There is one authoritative, rotating copy per user.
  3. Refresh orchestration. A refresh reads the latest on-disk credential, exchanges it, and persists the rotated result. Because a rotating refresh token is single-use, this read-modify-write is serialized by an in-process mutex plus a cross-process file lock (refresh.lock).
  4. Wiring. The refresher is injected through the default factory and invoked at each token consumption seam: the HTTP transport, gh auth token, gh auth status, the git credential helper, and the agent-task/CAPI client.

Key design decisions and challenges

Each of these is expanded in the PR that implements it; short form here so a reviewer sees the whole picture first.

1. Double-spend of a single-use refresh token (the refresh lock)

Rotating refresh tokens are single-use. With today's extensive use of agents and concurrent workflows, two gh processes sharing an account can each hit an expired token at the same time, read the same refresh token from disk, and both POST it. One exchange wins, the other is rejected, and many providers revoke the whole token family on reuse, logging the user fully out. We guard exactly this: the refresh path (and LoginRefreshable, which writes the same slot) takes a lock and reloads before spending. We deliberately do not lock login, switch, or logout: they do not spend a single-use secret, they are interactive and one-at-a-time, and their worst race is a benign last-writer-wins. The lock artifacts are refresh-specific by name (refresh.lock, refreshLockTimeout, withFreshConfigForRefresh) to make the scope obvious.

2. One authoritative copy per user (storage)

A refreshable credential is stored only in the per-user slot, with no host-level active duplicate, so a stale active-slot token can never outrank the freshly rotated one. Activation confirms the per-user record and clears any host-level active representation; removal and write-back touch only the per-user slot.

3. Not caching short-lived tokens in git (git 2.46)

When a short-lived credential is served to git through gh auth git-credential, a caching helper chained in front of gh could store it and keep serving it after it has rotated. git 2.46 added the authtype capability, which lets gh mark the credential ephemeral so a caching helper refuses to store it. On older git the capability is unavailable, so gh still returns the token but prints a one-line warning advising an upgrade. This is framed as a recommendation tied to the --short-lived opt-in, not a hard requirement for all git operations.

4. Never storing a refreshable token in a non-gh git helper

During login/refresh, gh can offer to configure an already-present third-party credential helper. That helper has no knowledge of refresh, so it would persist a soon-stale secret and keep serving it. For refreshable credentials gh now stores nothing in that helper: it rejects any existing credential for the host (so the problem surfaces immediately) and warns with the exact remedy, gh auth setup-git --hostname HOST. We considered storing-with-warning, an inline prompt to switch to the gh helper, and re-writing the helper on every refresh, and chose the simplest correct option (reject and warn) that keeps one coherent stance: refreshable tokens live only where gh can rotate them.

5. gh auth token --secure-storage grows four cases

gh auth token is also how go-gh (and therefore every extension) fetches a stored token, via a hidden --secure-storage flag historically meaning "keyring only." Refreshable tokens break that: they live under a separate key go-gh does not know, and gh owns their lifecycle. The run function is now an explicit four-case switch over --no-refresh x --secure-storage. The notable case is --secure-storage on a refreshable token: gh returns it regardless of where it is stored, because go-gh has no other way to reach it. Non-refreshable callers still get the exact old strict behavior.

6. No GHES / feature-detection gate

Refreshable tokens are requested by adding offline_access to the device flow. A server that does not support refresh simply ignores the scope and returns a normal non-expiring token, which the login path already treats as the ordinary case. There is nothing version-specific to detect, so no capability probe and no // TODO <cleanup> gate is added; older GHES keeps working unchanged.

7. Keeping refresh traffic out of the debug log

GH_DEBUG=api prints a verbose trace of HTTP traffic, but the token-refresh exchange is deliberately kept out of it. The refresher uses a dedicated HTTP client configured for headline-only logging, so a refresh shows at most a single head line (the token endpoint URL and its timing), never the request and response bodies. This matters because those bodies carry live secrets: the refresh token going out and a freshly issued access token coming back. Debug logs are long, and users routinely paste them into issues or share them for troubleshooting without scrubbing every line, so dumping a full refresh trace would leak rotating credentials into a trail that outlives the exchange. Keeping the refresh exchange headline-only is a deliberate privacy-hardening choice, not an oversight. A testing-only override, GH_DEBUG_REFRESH_TOKEN, can force the verbose refresh trace for local debugging, and it is removed in the polish PR.

8. Acceptance-test automation was not pursued

The feature is entered through the interactive OAuth device flow, where a human approves the grant in a browser. Automating that end to end is challenging, and I did not investigate how it could be done as part of this work, so the feature is currently not covered by the acceptance suite. Coverage instead comes from unit tests around storage, the refresh orchestration (lock, reload, rotate, persist), and each wired call site. The real device flow is verified manually, but not ad hoc: the final PR in the stack (#14456) adds two guided end-to-end scripts (see End-to-end verification below) that walk a reviewer through every seam. Adding automated acceptance coverage is left as an open question for follow-up.

The stack (review bottom to top)

Each PR is based on the tip of the PR below it.

  1. Foundation (#14450): gh.Credential, storage-interface migration, blocking file lock.
  2. Domain and logic (#14451): refreshable credential types and refresher interface, the OAuth refresher, refreshable storage, auth-config integration, the API refresh hook, and factory wiring.
  3. Login, refresh, and git credential (#14452): --short-lived, storing refreshable credentials at login, the non-gh-helper rejection, and the ephemeral/non-cacheable git credential path.
  4. gh auth token (#14453): refresh before printing, and the --secure-storage four-case handling.
  5. gh auth status (#14454): show and refresh short-lived token details.
  6. agent-task/CAPI (#14455): refresh short-lived tokens per CAPI request.
  7. End-to-end verification (#14456): two guided, interactive scripts that walk a reviewer through the whole feature (see End-to-end verification below).

End-to-end verification

The top of the stack (#14456) adds two guided, interactive verification scripts under script/. They are review aids, not merged automated tests: each walks a reviewer through a sequence of Given / When / Then scenarios one command at a time, printing what it is about to do, waiting for confirmation, running against a real build of the stack, and auto-checking what it can. To exercise expiry on demand they use the testing-only env overrides (GH_AT_EXPIRES_IN, GH_RT_EXPIRES_IN) and detect whether a refresh happened from gh's own output and debug log, so a reviewer never has to wait for a real token to expire. These hacks are shown as short notes as they run and are removed by the polish PR's cleanup noted below.

script/refreshable-token-e2e.sh drives the gh commands directly, across both storage themes (keyring via --keyring, plain config via --config). It covers login with --short-lived, automatic refresh inside an ordinary API call, gh auth status (table and JSON, with and without --no-refresh), gh auth token (including --secure-storage and --no-refresh), the GH_TOKEN never-refreshed cases, and gh auth refresh.

script/refreshable-token-gitcredential-e2e.sh verifies the same feature from git's point of view, driving gh as git's credential helper. It covers logging in over HTTPS, gh auth setup-git, serving a still-valid token without refreshing, refreshing an expired token before handing it to git, the git 2.46 ephemeral marking versus the older-git non-cacheable warning, and the rejected-refresh-token case where gh clears the dead credential and tells you (through git) to run gh auth login. It must be run from a real terminal outside VS Code, whose integrated terminal injects its own credential helper that would intercept git before gh.

Status, scope, and follow-ups

This stack is intended for review only. It is not merge-ready as-is. Before the final merge we will add a polish PR on top of the stack and land it with no testing or debugging leftovers. Specifically:

  • Remove the testing-only hacks: the token-expiry env overrides (GH_AT_EXPIRES_IN, GH_RT_EXPIRES_IN) in the authflow refresh path, and the refresh-debug logging override (GH_DEBUG_REFRESH_TOKEN) in the factory. Grep for TESTING-ONLY HACK and TESTING HACK ONLY.
  • Release dependencies: tag and publish cli/go-gh and cli/oauth with the refreshable-token changes this stack relies on, then bump the pins here (dropping the interim build(deps) pin) and re-run go mod tidy. The upstream changes are in cli/go-gh#298 and cli/oauth#143.
  • Document the git 2.46 recommendation for short-lived credentials over git in the release notes.

How to try this

The whole stack lives on the top branch, so you can build the feature from a single checkout. This is for hands-on testing only; read the warning below before you point it at credentials you care about.

  1. Build from source. Check out the top of the stack (the PR 7 branch, babakks/refresh-token-e2e, #14456) and build it:

    git clone -b babakks/refresh-token-e2e https://github.com/cli/cli.git
    cd cli
    make
    

    The binary lands at ./bin/gh.

  2. Opt in at login. Run ./bin/gh auth login --short-lived and complete the device flow in your browser. If the server honors the request you get a refreshable credential; if not, you get an ordinary non-expiring token and everything behaves as before.

  3. Inspect the credential. Run ./bin/gh auth status to see the short-lived token's expiry details.

  4. Wire up git. Run ./bin/gh auth setup-git so git uses gh as its credential helper. This matters because gh refuses to hand a refreshable token to a third-party helper that cannot rotate it.

A few caveats while you test:

  • git 2.46 or newer is recommended. Only there can gh mark the credential ephemeral so a caching helper refuses to store a soon-stale token; on older git you still get the token plus a one-line warning.
  • Third-party tooling may lag. Extensions and other clients that read the token through go-gh may not handle short-lived credentials yet.
  • Expect to re-authenticate afterward. Running this build rewrites your credential storage, so switching back to a released gh will show no stored auth and you will need to log in again (see the warning below). After logging back in, you may want to run gh auth setup-git with your released gh to reset git's credential helper to its original state.

Warning for reviewers and testers

Checking out this branch to try it will erase the tokens already stored in your gh environment: you will be logged out and have to authenticate again. Do not run it against a gh setup whose credentials you cannot easily restore. Back up first, or expect to re-run gh auth login afterward.