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 <IP>" (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:
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), 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:
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
$ gh --version
gh version 2.92.0 (2026-04-28)
$ gh auth status
github.com
✓ Logged in to github.com account <github-username> (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 <IP>. (...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:
$ 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
GH_TOKEN="$(gh auth token)" gh api … — bypasses the keyring entirely via the env-var short-circuit in TokenFromEnvOrConfig. Reliable.
- Delete and recreate keychain entries — sometimes resets stale ACLs:
security delete-generic-password -s 'gh:github.com' -a '<your-username>'
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.
gh auth login --insecure-storage — stores the token plaintext in hosts.yml instead of the keyring, sidestepping the keyring path entirely.
Related issues
- #12885 — Keychain lookup ignores account field. Same area; this issue is upstream of the silent-discard problem reported here.
- #10136 (closed by #11038) — Account incorrectly reported active. Prior fix in this area; did not cover the silent-discard symptom.
- #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) — Multiple users report the same
GH_TOKEN="$(gh auth token)" gh api … workaround independently.
Summary
When
gh's macOS Keychain lookup fails (most commonly via the keyring's 3-second timeout),gh apiandgh api graphqlsilently send requests without anAuthorizationheader, hitting the unauthenticated IP rate-limit instead of erroring out. Meanwhilegh auth statusandgh auth tokenmay 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 downstreamHTTP 401(REST) orHTTP 403 "API rate limit exceeded for <IP>"(GraphQL), with no signal that this is a local auth-resolution problem rather than a server-side or token problem.This is reproducible on
gh2.92.0(latest, released 2026-04-28) on macOS.Root cause
The bug is silent error discard, in two places:
1.
(*AuthConfig).ActiveTokenswallows keyring errorsIn
internal/config/config.go:237-260:When
TokenFromKeyringForUserand the unkeyed-fallbackTokenFromKeyringboth fail (e.g., both return*keyring.TimeoutErrorfrominternal/keyring/keyring.go:56),ActiveTokenreturns("", source)with no way for the caller to distinguish "this host has no token configured" from "the keyring read failed."2.
AddAuthTokenHeader.RoundTripdiscards the (already-empty) error pathIn
api/http_client.go:120: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
RoundTripperproceeds with noAuthorizationheader, 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.comkeychain 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., whenghis invoked from a child process without a foreground terminal). The 3-second timeout ininternal/keyring/keyring.gothen fires. This is a normal failure mode on macOS — what's wrong is the silent-discard response to it.Steps to reproduce
Confirming the diagnosis
The env-var path bypasses the keyring entirely (via
TokenFromEnvOrConfigatinternal/config/config.go:241), proving the failure is in the keyring read path, not the token, scopes, network, orhosts.yml:Expected behavior
When
ActiveTokencannot resolve a token because of a keyring access failure (TimeoutErroror other non-ErrNotFounderror),gh apishould fail with a clear error message identifying the keyring as the cause, not silently send an unauthenticated request.Actual behavior
gh apisilently omits theAuthorizationheader. There is no log, warning, or surfaced error — the user sees only the downstreamHTTP 401/HTTP 403and has no way to identify that the local keyring is the cause without instrumenting the gh source.Environment
gh --versiongh version 2.92.0 (2026-04-28)hosts.ymloauth_tokenfield; keyring is canonicalGITHUB_TOKEN/GH_TOKEN/GH_HOST/GITHUB_API_URLgh config listWorkarounds for users hitting this today
GH_TOKEN="$(gh auth token)" gh api …— bypasses the keyring entirely via the env-var short-circuit inTokenFromEnvOrConfig. Reliable.ghwrites two keychain entries on login by design — one keyed under your username (per-user storage), one with emptyacct(the active-slot pointer used forgh 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.gh auth login --insecure-storage— stores the token plaintext inhosts.ymlinstead of the keyring, sidestepping the keyring path entirely.Related issues
GH_TOKEN="$(gh auth token)" gh api …workaround independently.