Skip to content

Instantly share code, notes, and snippets.

@zircote
Created July 7, 2026 16:35
Show Gist options
  • Select an option

  • Save zircote/458fe655bc75dae237707f8494de34b1 to your computer and use it in GitHub Desktop.

Select an option

Save zircote/458fe655bc75dae237707f8494de34b1 to your computer and use it in GitHub Desktop.
Runbook: gh CLI cross-session identity bleed (multi-account)

Runbook: gh CLI cross-session identity bleed (multi-account)

Overview

Covers one symptom: gh operations under one GitHub account failing or silently behaving as a different, unrelated account, with no explicit account switch performed in the current session. Applies to any machine running multiple concurrent gh-authenticated processes (interactive shells, IDE/agent sessions, their background tool servers) under more than one GitHub account.

Prerequisites & Access

  • gh CLI installed, both accounts already authenticated at least once (gh auth status lists both under Logged in to github.com account).
  • Shell access to inspect ~/.config/gh/hosts.yml and running processes (ps, lsof).

Detection

Symptom: a gh command that should run as one account errors or acts as the other, e.g.:

GraphQL: Unauthorized: As an Enterprise Managed User, you cannot access
this content (mergePullRequest)

Confirm by running gh auth status — the Active account: true line will show the wrong account for the work you're doing, with no gh auth switch having been run in this session.

Diagnosis

  1. gh auth status — confirms which account is currently active machine-wide.
  2. ps aux | grep -i gh / ps aux | grep -i mcp — look for concurrent sessions or background tool-server processes that might be touching GitHub auth.
  3. For any suspect process, lsof -a -p <pid> -d cwd — its working directory usually reveals which account's work it's doing.
  4. If it's a Node-based (or similar) tool server, grep its bundled source for a literal "gh" invocation via a subprocess call to confirm it shells out to the gh binary rather than using its own token.

Root cause, confirmed on this machine: gh's "active account" is a single field in one shared file, ~/.config/gh/hosts.yml — not scoped per-process, per-shell, or per-session. Any concurrent gh process on the machine can flip it. A concurrent agent session working in an unrelated project had a background tool-server process whose bundled code calls the equivalent of gh auth token directly — reading whatever account happens to be active at that moment, with no awareness of which account it actually needs. This is a documented, acknowledged gh CLI limitation (cli/cli Discussion #4221: "the active account is global, not per-directory or per-repo"), not a one-off misconfiguration.

Remediation

Give every account its own fully isolated gh config root (GH_CONFIG_DIR), selected by working directory, at the one place every gh invocation on the machine actually resolves from — not a shell function (misses non-shell callers), not an IDE/agent lifecycle hook (its process exits before anything it exports can persist to later, independent command invocations).

  1. Find the universal entry point. Check which -a gh — if a user-owned directory (e.g. ~/.local/bin) precedes the real binary in $PATH, and holds a script rather than the real executable, that's the one file every process resolves gh through. Confirm with ps eww <pid> on a concurrent process you suspect — its PATH should show the same ordering. (On this machine, such a shim already existed for an unrelated compatibility patch, which made this the natural place to extend rather than build a parallel mechanism.)

  2. Create one isolated config root per account:

    mkdir -p ~/.config/gh-<account-a> ~/.config/gh-<account-b>
    cp ~/.config/gh/config.yml ~/.config/gh-<account-a>/config.yml
    cp ~/.config/gh/config.yml ~/.config/gh-<account-b>/config.yml
  3. Migrate existing credentials — no new OAuth flow needed:

    GH_CONFIG_DIR=~/.config/gh-<account-a> gh auth login --hostname github.com \
      --git-protocol ssh --with-token <<< "$(gh auth token --user <account-a>)"
    
    GH_CONFIG_DIR=~/.config/gh-<account-b> gh auth login --hostname github.com \
      --git-protocol ssh --with-token <<< "$(gh auth token --user <account-b>)"
  4. Add path-based dispatch to the universal entry point, right before it execs the real binary — only when the caller hasn't already set GH_CONFIG_DIR explicitly:

    if [ -z "${GH_CONFIG_DIR:-}" ]; then
      case "$PWD" in
        "$HOME"/Projects/<account-b-path-prefix>-*)
          export GH_CONFIG_DIR="$HOME/.config/gh-<account-b>" ;;
        *)
          export GH_CONFIG_DIR="$HOME/.config/gh-<account-a>" ;;
      esac
    fi
    exec "$REAL_GH" "$@"
  5. Remove any earlier, partial attempt at this (e.g. a shell function in .bashrc/.zshrc) so there's exactly one mechanism, not two that can disagree.

Escalation

Solo-maintained tooling; no on-call rotation. If the fix doesn't hold (active account still flips after remediation), stop and re-run Diagnosis steps 2–4 against every currently-running gh-adjacent process before changing anything further — don't guess at a second cause.

Verification & Rollback

Verify:

  1. GH_CONFIG_DIR=~/.config/gh-<account-a> gh auth status → account A.
  2. GH_CONFIG_DIR=~/.config/gh-<account-b> gh auth status → account B.
  3. cd into an account-A project, unset GH_CONFIG_DIR, gh auth status → account A, with existing aliases/editor/pager preferences intact.
  4. cd into an account-B project (ideally the exact path the suspect concurrent process was running from), unset GH_CONFIG_DIR, gh auth status → account B.
  5. Hash ~/.config/gh/hosts.yml before and after steps 3–4 — must be byte-identical. This is the actual proof: the shared file is never touched by dispatch-mediated calls again.
  6. Anything the universal entry point special-cased before this change (an unrelated compatibility patch, in this case) still works unchanged.

Rollback: delete the dispatch block added in Remediation step 4 from the universal entry point, restoring it to pass straight through to the real binary. The two new GH_CONFIG_DIR roots and their credentials are inert if unused — no need to delete them to roll back the behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment