cli / cli/cli

`gh api` silently sends unauthenticated requests when keychain access fails

Open
#13,317 7 comments 0 reactions 0 assignees View on GitHub
auth bug gh-api needs-investigation priority-3 stale
Dominant language
Go
Stars
46.3k
Forks
9k
Avg merge
2d 9h
Merged PRs (30d)
89

Description

## Summary

When `gh`'s macOS Keychain lookup fails (most commonly via the keyring's 3-second timeout), `gh api` and `gh api graphql` silently send requests **without an `Authorization` header**, hitting the unauthenticated IP rate-limit instead of erroring out. Meanwhile `gh auth status` and `gh auth token` may still work because they exercise the keyring at different times when access is uncontended. There is no log line, warning, or surfaced error indicating that authentication resolution failed — users see only a downstream `HTTP 401` (REST) or `HTTP 403 "API rate limit exceeded for "` (GraphQL), with no signal that this is a local auth-resolution problem rather than a server-side or token problem.

This is reproducible on `gh` `2.92.0` (latest, released 2026-04-28) on macOS.

## Root cause

The bug is **silent error discard**, in two places:

### 1. `(*AuthConfig).ActiveToken` swallows keyring errors

In [`internal/config/config.go:237-260`](https://github.com/cli/cli/blob/trunk/internal/config/config.go#L237):

```go
func (c *AuthConfig) ActiveToken(hostname string) (string, string) {
...
if user, err = c.ActiveUser(hostname); err == nil {
token, err = c.TokenFromKeyringForUser(hostname, user)
}
if err != nil {
token, err = c.TokenFromKeyring(hostname)
}
if err == nil {
source = "keyring"
}
return token, source // <— err is dropped on the floor
}
```

When `TokenFromKeyringForUser` and the unkeyed-fallback `TokenFromKeyring` both fail (e.g., both return `*keyring.TimeoutError` from [`internal/keyring/keyring.go:56`](https://github.com/cli/cli/blob/trunk/internal/keyring/keyring.go#L56)), `ActiveToken` returns `("", source)` with no way for the caller to distinguish "this host has no token configured" from "the keyring read failed."

### 2. `AddAuthTokenHeader.RoundTrip` discards the (already-empty) error path

In [`api/http_client.go:120`](https://github.com/cli/cli/blob/trunk/api/http_client.go#L120):

```go
if token, _ := cfg.ActiveToken(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
}
```

The empty-token case is treated as "no auth needed, send anonymously" — appropriate for genuinely anonymous endpoints but indistinguishable from "the keyring lookup just timed out." The `RoundTripper` proceeds with no `Authorization` header, the request hits the unauth IP bucket, and the user sees only the downstream 401/403.

These two compose into the user-visible failure: a request that looks like a server-side rate-limit or authorization issue but is actually a local-keychain-access problem with no surfaced signal.

### Why does the macOS keychain timeout fire?

On systems with a long-lived `gh:github.com` keychain entry written by an older gh version, the macOS Keychain Services ACL can be set to "ask before access" rather than "always allow this binary," and an interactive approval dialog can fail to render in non-TTY contexts (e.g., when `gh` is invoked from a child process without a foreground terminal). The 3-second timeout in `internal/keyring/keyring.go` then fires. This is a normal failure mode on macOS — what's wrong is the silent-discard response to it.

## Steps to reproduce

```bash
$ gh --version
gh version 2.92.0 (2026-04-28)

$ gh auth status
github.com
✓ Logged in to github.com account (keyring)
- Active account: true
- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'

$ TOKEN=$(gh auth token)

# token itself is fully valid — direct curl works, returns 200 with full 5000/hr bucket:
$ curl -sI -H "Authorization: Bearer $TOKEN" https://api.github.com/user | head -1
HTTP/2 200

# but gh api silently sends with no Authorization header:
$ gh api user --include 2>&1 | grep -iE '^(HTTP|x-ratelimit)'
HTTP/2.0 401 Unauthorized
X-Ratelimit-Limit: 60
X-Ratelimit-Resource: core
# ↑ unauthenticated REST IP bucket, not the authenticated 5000/hr

$ gh api graphql -f query='query { viewer { login } }' --include 2>&1 | grep -iE '^(HTTP|x-ratelimit)'
HTTP/2.0 403 Forbidden
X-Ratelimit-Limit: 0
X-Ratelimit-Resource: graphql
# body: "API rate limit exceeded for . (...Authenticated requests get a higher rate limit...)"
```

### Confirming the diagnosis

The env-var path bypasses the keyring entirely (via `TokenFromEnvOrConfig` at `internal/config/config.go:241`), proving the failure is in the keyring read path, not the token, scopes, network, or `hosts.yml`:

```bash
$ GH_TOKEN="$(gh auth token)" gh api user --include 2>&1 | grep -iE '^(HTTP|x-ratelimit)'
HTTP/2.0 200 OK
X-Ratelimit-Limit: 5000
X-Ratelimit-Resource: core
```

## Expected behavior

When `ActiveToken` cannot resolve a token because of a keyring access failure (`TimeoutError` or other non-`ErrNotFound` error), `gh api` should fail with a clear error message identifying the keyring as the cause, **not** silently send an unauthenticated request.

## Actual behavior

`gh api` silently omits the `Authorization` header. There is no log, warning, or surfaced error — the user sees only the downstream `HTTP 401` / `HTTP 403` and has no way to identify that the local keyring is the cause without instrumenting the gh source.

## Environment

| | |
|---|---|
| `gh --version` | `gh version 2.92.0 (2026-04-28)` |
| OS | macOS (darwin 25.4.0) |
| Shell | zsh |
| Auth backend | keyring (macOS Keychain) |
| `hosts.yml` | stub entry only — no `oauth_token` field; keyring is canonical |
| `GITHUB_TOKEN` / `GH_TOKEN` / `GH_HOST` / `GITHUB_API_URL` | all unset |
| `gh config list` | vanilla — no overrides |

## Workarounds for users hitting this today

1. **`GH_TOKEN="$(gh auth token)" gh api …`** — bypasses the keyring entirely via the env-var short-circuit in `TokenFromEnvOrConfig`. Reliable.
2. **Delete and recreate keychain entries** — sometimes resets stale ACLs:
```bash
security delete-generic-password -s 'gh:github.com' -a ''
security delete-generic-password -s 'gh:github.com' # the unkeyed "active slot"
echo "$TOKEN" | gh auth login --with-token # rewrites both with current ACLs
```
Note: `gh` writes two keychain entries on login by design — one keyed under your username (per-user storage), one with empty `acct` (the active-slot pointer used for `gh auth switch`). This is **not** a bug; both entries are intentional. The bug is the silent failure when looking them up, not the dual-write.
3. **`gh auth login --insecure-storage`** — stores the token plaintext in `hosts.yml` instead of the keyring, sidestepping the keyring path entirely.

## Related issues

- [#12885](https://github.com/cli/cli/issues/12885) — Keychain lookup ignores account field. Same area; this issue is upstream of the silent-discard problem reported here.
- [#10136](https://github.com/cli/cli/issues/10136) (closed by [#11038](https://github.com/cli/cli/pull/11038)) — Account incorrectly reported active. Prior fix in this area; did not cover the silent-discard symptom.
- [#10032](https://github.com/cli/cli/issues/10032) (closed) — "401 Error at every turn." Symptom-only match; root cause undiagnosed at the time and is plausibly the same silent-discard bug.
- [#8347 (discussion)](https://github.com/cli/cli/discussions/8347) — Multiple users report the same `GH_TOKEN="$(gh auth token)" gh api …` workaround independently.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.