Making the GitHub CLI's credential helper work with multiple GitHub accounts

Back in 2023 I wrote about automatically configuring Git based on the remote repository URL, using Git’s hasconfig:remote conditional includes to switch identity, email and signing key depending on which client’s repository I was working in. That configuration solved the identity half of working across multiple GitHub accounts, but it didn’t deal with the authentication side of things.

Repository not found

I recently switched from a client repository over to one of my own personal ones, and IntelliJ couldn’t pull:

remote: Repository not found. https://github.com/eddgrant/spot-secured.git/

The repository obviously exists - I own it. The hasconfig:remote setup from the 2023 post was correctly applying my personal name, email and signing key. But Git was trying to authenticate using the wrong account, hence the “Repository not found” error.

Are you affected?

This post is for you if:

  • you use gh (the GitHub CLI) as your Git credential helper (credential.https://github.com.helper = !gh auth git-credential in your .gitconfig - check with git config --get credential.https://github.com.helper),
  • you’re logged in to more than one GitHub account via gh auth login (check with gh auth status), and
  • you switch between them by directory or by client, the way the 2023 post describes.

If any of that doesn’t apply, you just use a single GitHub account, or you’re happy using SSH keys - you probably won’t hit this.

Why it happens: gh only ever has one active account

gh auth status will happily show you several logged-in accounts:

$ gh auth status
github.com
  ✓ Logged in to github.com account eddgrant (keyring)
  - Active account: true
  ...
  ✓ Logged in to github.com account funkycorp (keyring)
  - Active account: false
  ...
  ✓ Logged in to github.com account groovycorp (keyring)
  - Active account: false
  ...

But gh auth git-credential - the command Git actually calls to get a username/password - doesn’t consult any of that context about which repository you’re in. It just returns the token for whichever account is currently marked active. I tested this by calling it directly with an explicit username, the way Git does when credential.<url>.username is set:

$ printf 'protocol=https\nhost=github.com\nusername=eddgrant\n' | \
  gh auth git-credential get
exit=1

Rather than looking up the eddgrant account, it just fails.

The only thing that changes which token you get is the global, stateful gh auth switch - which is precisely the kind of manual context-juggling the 2023 hasconfig:remote post was meant to eliminate.

Upon realising this I expected to discover that the gh cli supported multiple account credentials, however I then discovered several open GitHub CLI issues about this shortcoming, the oldest going back to 2020 with over a hundred comments:

  • cli/cli#326 - “Allow multiple account credentials” (Feb 2020, still open)
  • cli/cli#9111 - “Allow gh auth git-credential get to get a token for non-active user” (2024)
  • cli/cli#8875 - “Improve multi-account git credential support when gh is not configured as git credential helper” (2024)
  • cli/cli#11938 - auth git-credential fails if username provided by git does not match active user” (2025)

A gh maintainer summed it up well on that last issue: “gh-cli is really centered around tokens and sort of has users as a bit of an afterthought.” No fix has shipped yet.

The workaround

That last issue mentions a maintainer-suggested workaround that avoids gh auth switch entirely. Shout-out to @babakks, who posted it. Instead of asking gh for “the active account’s” token, ask it for a named account’s token directly, using gh auth token, and hand that to the credential helper via the GH_TOKEN environment variable:

GH_TOKEN="$(gh auth token --hostname github.com --user eddgrant)" \
  gh auth git-credential

This bypasses the single-active-account model completely: there’s no global state to switch, so there’s no race between concurrent git operations against different accounts either.

Worked example

This slots nicely into the hasconfig:remote setup from the 2023 post, because those per-identity include files are already scoped to exactly the right repositories.

As a recap, the top-level ~/.gitconfig conditionally includes a file per client, based on a pattern match against the repository’s remote URL:

[includeIf "hasconfig:remote.*.url:[email protected]:funkycorp/**"]
    path = "~/.gitconfig.funkycorp.inc"
[includeIf "hasconfig:remote.*.url:https://github.com/funkycorp/**"]
    path = "~/.gitconfig.funkycorp.inc"

[includeIf "hasconfig:remote.*.url:[email protected]:groovycorp/**"]
    path = "~/.gitconfig.groovycorp.inc"
[includeIf "hasconfig:remote.*.url:https://github.com/groovycorp/**"]
    path = "~/.gitconfig.groovycorp.inc"

(Two includeIf lines per client, one for SSH-style remotes and one for HTTPS-style, since I can’t guarantee every repo was cloned the same way.)

Previously, each included file only carried identity and signing settings, but we can easily add the credential configuration too:

~/.gitconfig.funkycorp.inc:

[user]
    email = [email protected]
    name = egfunk1
    signingkey = <my funky corp gpg signing key>
[commit]
    gpgsign = true

[credential "https://github.com"]
    helper =
    helper = !GH_TOKEN="$(gh auth token --hostname github.com --user egfunk1)" gh auth git-credential

~/.gitconfig.groovycorp.inc:

[user]
    email = [email protected]
    name = eggroove1
    signingkey = <my groovy corp gpg signing key>
[commit]
    gpgsign = true

[credential "https://github.com"]
    helper =
    helper = !GH_TOKEN="$(gh auth token --hostname github.com --user eggroove1)" gh auth git-credential

Note: The helper = line with no value resets the helper list before adding the new one - without it, you’d end up with both the global default helper and this override registered, and Git tries them in order.

Because these blocks live inside files that only get included when the remote URL matches that client’s org, we now get the same “just works, per-repository” guarantee for authentication that the 2023 post provided, but for identity.

I tested this by deliberately setting a different account as active in gh and confirming the override still serves the right token regardless:

$ gh auth switch --hostname github.com --user eggroove1
✓ Switched active account for github.com to eggroove1

$ cd ~/repos/funkycorp-project
$ printf 'protocol=https\nhost=github.com\n' | git credential fill
username=x-access-token
password=gho_<funky corp's token, not groovy corp's>

Checking the security posture

This is going into daily use across client work, so before trusting it I wanted to actually check the mechanism rather than assume a shell one-liner is fine because it looks fine. A few things I went and verified:

  • The .inc files only ever contain an account name, never a token. The token itself stays in gh’s OS keyring and is resolved just-in-time, so these dotfiles are safe to keep in a version-controlled dotfiles repo.
  • No token ever touches argv - nothing here passes a secret as a command-line argument (which anyone on the box could read via ps); it only ever flows through an environment variable assignment or a stdout pipe.
  • gh’s own docs confirm GH_TOKEN (or GITHUB_TOKEN) overrides stored credentials entirely, for every gh invocation - so it’s worth checking you don’t already export one of those globally in your shell profile for some other reason (CI parity, an old script, whatever). If you do, it’ll silently short-circuit this whole per-account mechanism back to a single flat token everywhere.

But there’s a catch…

One thing did catch me out, though, it fails open:

I tried breaking it on purpose: what happens if gh auth token --hostname github.com --user egfunk1 fails - a typo in the username, the account gets logged out, a gh upgrade changes something?

Here’s what happened:

$ GH_TOKEN="$(gh auth token --hostname github.com --user does-not-exist 2>/dev/null)" \
  gh auth git-credential get <<< $'protocol=https\nhost=github.com\n'
username=eggroove1
password=gho_<whichever account happens to be active>
exit=0

The authentication failure leaves GH_TOKEN set to an empty string, returns an exit code of 0 and gh treats an empty GH_TOKEN as though it were never set at all - so it silently falls back to whatever account happens to be globally active. No error, no warning.

Fortunately we can address this with a 2 character change: use && between the commands so a failure short-circuits the whole thing instead of falling through:

[credential "https://github.com"]
    helper =
    helper = !TOKEN="$(gh auth token --hostname github.com --user egfunk1)" && GH_TOKEN="$TOKEN" gh auth git-credential

Same test, hardened version:

$ TOKEN="$(gh auth token --hostname github.com --user does-not-exist 2>/dev/null)" && \
  GH_TOKEN="$TOKEN" gh auth git-credential get <<< $'protocol=https\nhost=github.com\n'
exit=1

Now a lookup failure aborts the credential request instead of silently authenticating as the wrong account, which is exactly what we want.

Why not just use SSH keys?

One alternative here is to give each identity its own SSH key and host alias in ~/.ssh/config, sidestepping gh’s credential helper entirely. I went the HTTPS + token route instead - because I think it has various benefits over SSH, and partly also because for some client work it’s not even a choice (governance often forbids personal SSH keys).

Summary

The fix, in the end, is just an extension of the same approach as used in the 2023 post - just pointed at authentication instead of identity. Ask gh for a named account’s token instead of whichever one happens to be active, and slot it into the same hasconfig:remote include files you already have. Just don’t skip the &&!

Feedback

If you’re in the same boat and use multiple GitHub accounts and the gc cli for credential helping, I’d love to hear how you’re handling it. Please leave a comment or reaction below.

Until next time!

Edd

Edd Grant

Got your multi-account gh setup working? If this untangled your credential helper, you can buy me a coffee. It’s about the price of, well, a coffee, takes 20 seconds, and needs no account. It genuinely makes my day.

A handful of lovely readers have already bought me a coffee — thank you, each one's a real treat. ☕