Skip to content

gh api silently sends unauthenticated requests when keychain access fails #13317

@Iron-Ham

Description

@Iron-Ham

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

  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:
    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.
  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 — 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    authrelated to tokens, authentication state, or oauthbugSomething isn't workinggh-apirelating to the gh api commandneeds-investigationCLI team needs to investigatepriority-3Affects a small number of users or is largely cosmetic

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions