You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
gh auth saves plain text token, even though I have keyring #8954
gh auth login stores credentials in plain text (at ~/.config/gh/hosts.yml).
However, I have keyring installed:
➜ keyring
Usage: keyring [get|set|del] SERVICE USERNAME
Options:
-h, --help show this help message and exit
-p KEYRING_PATH, --keyring-path=KEYRING_PATH
Path to the keyring backend
-b KEYRING_BACKEND, --keyring-backend=KEYRING_BACKEND
Name of the keyring backend
--list-backends List keyring backends and exit
--disable Disable keyring and exit
Why aren't the credentials saved in the keyring?
I am on a headless Ubuntu machine, connected remotely by SSH.
➜ gh --version
gh version 2.47.0 (2024-04-03)
https://github.com/cli/cli/releases/tag/v2.47.0
Reacted by verehfy, Shane Lawrence, 宁静致远, Arsen Nuramatov, Enrico Pezzano, Benoît, ryan4yin | 二花, Aakarshit Uppal, yuyaprgrm, Jérôme MEVEL and 7 more
✓ Authentication complete.
- gh config set -h github.com git_protocol https
✓ Configured git protocol
! Authentication credentials saved in plain text
✓ Logged in as b4shful
what the fuck
This should not be the default, in fact there's an --insecure-storage flag for the people who want to save in plaintext. I'd say that if it can't use the keyring for some reason, it should error out and tell the user what went wrong.
Under no circumstances should it save a token in plaintext when the user hasn't asked for this.
Reacted by 3f6a, tyler36, Joshua Boelter, Shravan Kale, Ivar, Nick, sps024, NotEvenTechnical, grassy, Minseok Choe and 40 more
@williammartin Is there anything in the works about this?
I have seen this happen to a lot of people (including myself) time and time again.
Oftentimes people are trying to do the right thing, they install and set up a keyring/credential store and even point github cli to it, and then they run gh auth login again, only to be told the credentials were saved in plain text.
Then it becomes a frantic search for where the credentials got saved to delete the insecure config file.
I'd argue that the default behaviour should NEVER be to save in plain text, the presence of the --insecure-storage flag should be the ONLY reason that credentials/tokens get saved in plain text. If gh-cli can't work with the keyring/cred store, then it should error out and give the user some information so they could perhaps fix it. Because right now it's very difficult to figure out what's going wrong, and this in turn makes it difficult to use GH CLI with proper credential security.
(also PS: apologies, please excuse my swearing in the previous comment - it was my genuine reaction upon seeing that CLI message. Although given the times we live in, perhaps this reaction to security issues is justified!)
Reacted by Shahan Arshad, David Natanael, francis-oc-ops, Aleksei Rechinskii, Ezry, dericci, Jakob, Jason Weinzierl and Sukka
Firstly, @3f6a, I'm sorry about this going unanswered, there was an extended period where we were dealing with a lot of team churn, and some things got lost. In terms of your original question, we try to use the credential manager as provided over dbus by the zalando go-keyring module. Right now I have no idea why your keyring isn't being used because unfortunately, if there is an error it suppressed and we move on to insecure storage. Why do we do that? Well it relates to:
what the fuck
So just a little history before we figure out what to do next: Originally, the GitHub CLI only supported writing the token insecurely to a file. In fact it was only February 2023 that ghbegan using keyrings for secure storage. When this was added, it was opt-in via --secure-storage because the team didn't want to break existing workflows. Later, this was made the default and the previous insecure behaviour was opt in via --insecure-storage. The fallback behaviour remained in place however, for the interactive flow we can debate that value of that (it might even be negative), for the non-interactive flow (i.e. --with-token), no longer falling back is a breaking change.
So, hopefully with that context you can understand why the situation is as it is.
What Next?
Non-Interactivity
It's don't think it's acceptable to break scripts out there that are calling gh auth login --with-token on environments that don't have a keyring available. However, we can consider some mitigations and nudges:
We could add a --no-insecure-fallback option for users who really really don't want this to happen
We could add a no_insecure_fallback config option for gh config that makes it persistent
We could add a warning on stderr, just in case someone looks at their script output and realises that something unexpected is happening
Interactivity
With the interactive flows we have a bit more flexibility around changing behaviour. I think there are three paths we could take here, each with a different tradeoff of safety vs convenience.
--no-insecure-fallback/no_insecure_fallback idea from above
As above, we could allow users to opt-in to turning this off. The downside of course is that this is opt-in, which even with the warning, still ends up with your token potentially being written to disk.
Re-prompt
On a failure to use the keyring we could offer a prompt along the lines of:
Failed to save the token securely due to <>
Would you like to continue with insecure storage (y/N) ?
This is informative and allows for immediate recovery, which is nice since the user has probably gone through a whole oauth dance already.
Error
On a failure to use the keyring we could error out with the reason.
This might be immediately annoying to a user, but it's a pretty big nudge that they need to start using --insecure-storage from now on, which should be high friction.
My Recommendation
I think I would probably favour all the non-interactive options, and the last of the interactive options (erroring).
Both the re-prompt and error options would also help provide clarity to why keyring operations are failing, since the error is currently suppressed. This isn't the first time this has happened, I vaguely recall creating a branch specifically for someone to get this debug info, which is super lame.
It is pretty late here so I definitely need to sleep on this, and get input from the rest of the team. What do you think though?
I've went down a rabbit hole... on my personal machine, I run a window manager (Hyprland) which needs some prior configuration, but does work with GNOME Keyring. I've gotten used to that one, it can be hard to tell when it's configured right but I've done it enough to know by now.
However, the environment I'm currently working on, a headless Ubuntu 24.04 Server, is just running through a tty, no display manager, no desktop environment, and oh boy has it been a PAIN. I can't for the life of me get GNOME Keyring to work for a series of incredibly dumb reasons and despite even dumber workarounds.
It's expected that the default collection login exists in the keyring, because it's the default in most distros. If it doesn't exist, you can create it through the keyring frontend program Seahorse.
It's the default in most desktop environments, and most of them use GNOME Keyring, the ones that don't probably have no choice but to do things GNOME's way. And what about when you're not using a desktop environment?
Seriously though, even if you get GNOME Keyring working on a headless install, it won't create the default login keyring, and the reason why is (I wish I was kidding) it tries to use GNOME's prompter service for a password prompt, even if you give it the password via stdin! You also can't really manually create it either, as the only working way seems to be Seahorse, which is a GUI app.
It seems like a cascading series of decisions (go-keyring needing secret service (doable) and the login keyring (less doable), and GNOME Keyring being unusable on a proper headless (tty) install) which end up combining in an unfortunate way to make it impossible without a graphical environment. It's quite ironic considering the prevalence of headless and DE-less environments using git and gh.
I see the tricky situation you guys have here, I will come back to this with more input later, but for now it seems I have even more problems. This message is just background information, but it might be somewhat informative and fall under the category of "user input". At a later date I'll come back and give my thoughts specifically on the gh cli side.
It is pretty late here so I definitely need to sleep on this...
Have a wonderful rest, goodnight! <3
For me, back to trying every single org.freedesktop.secrets provider to see if any of them actually work.
Right now I have no idea why your keyring isn't being used
@williammartin Let me know if there are any tests I can do to give further information !
Note that the Ubuntu servers I'm using have graphical Ubuntu installed, though I connect to them with ssh (without GUI). Also, probably they do not have the latest versions of Ubuntu.
Is there a workaround for this so I can authenticate via non-plaintext (~/.config/gh/hosts.yml) on Ubuntu in WSL2? I tried adding export GITHUB_TOKEN=$(gh.exe auth token) to my .bashrc (and yes, gh.exe is in $PATH), but that didn't work (AI suggested that).
How can I properly authenticate securely on Ubuntu in WSL2? Does anyone have a way?
I use jupyterlab built on ubuntu noble for my work. Having github store credentials in plain text, with no recourse as keychain isn't supported on the headless containers is really really really really bad.
If someone managed to probe the server for those credentials it would be a complete disaster. This is a really major issue!
Posting this since this issue keeps coming back for WSL users. I laid out both a working interim fix and a thought-out actual permanent one.
Why this issue happens
gh stores tokens through github.com/zalando/go-keyring, which looks for a Secret Service on D-Bus, normally provided by gnome-keyring on a real desktop. On a normal Ubuntu desktop, logging in triggers PAM, which unlocks your keyring automatically and a session bus gets started as part of that login. WSL never runs a desktop login at all, so none of that happens. There's no session bus, and nothing to unlock the keyring even if you get one running.
This is also why installing libsecret-1-0 libsecret-1-dev libsecret-common gnome-keyring gnome-keyring-pkcs11 seahorse doesn't actually fix it. You can start gnome-keyring-daemon and export the D-Bus variables by hand, but the default collection never gets unlocked, since that unlock normally happens as a side effect of a login event that WSL never fires. So the Secret Service is either not advertised at all, or advertised and stuck locked, and gh just falls back to plaintext either way.
The interim workaround fix
( @3f6a , @bobsdacool , @futuremotiondev , @rmscode , @AlexKeySmith , @wolfthegreat , @b4shful )
Since recreating desktop login infrastructure inside a headless shell is more trouble than it's worth, the simplest fix right now is to skip Linux keyring entirely and let the Windows side of GitHub CLI handle storage instead, since gh.exe already has working credential storage through Windows.
1. Check that GitHub CLI is installed on Windows
gh.exe --version
If that fails, install GitHub CLI on your Windows machine from your WSL instance:
(If you already tried the libsecret/gnome-keyring route, you can remove those too with sudo apt remove libsecret-1-0 libsecret-1-dev libsecret-common gnome-keyring gnome-keyring-pkcs11 seahorse -y)
Pick SSH as the protocol and "Login with a web browser" as the auth method. Once it finishes, you'll see ✓ Authentication complete.
5. Verify
gh auth status
You should see you're logged in through your Windows environment, and ~/.config/gh stays empty since everything lives on the Windows filesystem instead.
6. Set up a WSL-native SSH key for git push
gh auth login in step 4 generates its SSH key on the Windows side (C:\Users\...\.ssh\), since that's where gh.exe is actually running. That's fine for gh itself, but native git commands run from WSL (git push, git pull, etc.) look in WSL's own ~/.ssh, so git won't find that key and git push will fail with Permission denied (publickey).
The simplest fix is to copy the key over from your Windows machine to WSL:
This way git push in WSL will authenticate with the same key gh used to authenticate with GitHub in step 4.
A permanent fix instead of a manual workaround
( @williammartin , @babakks )
Two directions that could actually close this for everyone instead of requiring the steps above:
Option 1: Auto re-exec into gh.exe when running under WSL. A Linux binary has no way to call Windows Credential Manager directly, the two operating systems don't share that kind of function-call boundary. The only way to actually reach Windows Credential Manager from inside WSL is to have an actual Windows program do the work, there's no shortcut around launching one. Concretely, here's what gh could do automatically:
On startup, before running any command, check /proc/version (or /proc/sys/kernel/osrelease) for the string microsoft, which is the standard, well-documented way any Linux program detects it's running under WSL.
If that matches, check whether gh.exe is reachable on PATH. This already works out of the box by default, since WSL automatically exposes Windows' PATH inside the Linux shell unless a user has specifically disabled that in wsl.conf.
If it's not found, there's no need to stop there either: WSL can invoke Windows executables directly, so gh could shell out to powershell.exe -Command "winget install --id GitHub.cli" itself and then re-check PATH, the same way it would call gh.exe in the next step. Nothing about the WSL boundary limits this to launching gh.exe specifically, any Windows executable is reachable the same way.
Once gh.exe is confirmed present (whether it was already there or just installed), re-run the same command as gh.exe instead, passing through the terminal input/output directly so the browser login flow and any prompts still work normally, then exit with whatever exit code gh.exe returned.
In plain terms, this is exactly what the manual alias gh="gh.exe" workaround does today, just built into gh itself. It doesn't touch the keyring library at all, it just hands the whole job to the Windows binary.
Option 2: Add an encrypted fallback, built directly into gh. gh depends on github.com/zalando/go-keyring, which only wraps the three OS-native stores (macOS Keychain, Linux Secret Service, Windows Credential Manager). It has no fallback backend of any kind, so this would be new code gh writes itself. In gh's own code, wherever it currently catches "keyring unavailable" and falls through to writing the token in plaintext, that's where this function would slot in instead, encrypting the token before it's written under ~/.config/gh rather than writing it raw. What key protects that encryption should depend on which protocol the user picked during gh auth login, since SSH and HTTPS leave gh in genuinely different positions here:
SSH users already have a key to lean on.age (and its Go library, filippo.io/age, with the agessh package) already supports encrypting to an existing ssh-ed25519/ssh-rsa public key and decrypting with the matching private key file, a documented existing pattern, not something new to invent. gh could reuse the same SSH key the user authenticated with, reading it on demand with no prompt. The fix here is narrow and specific: hosts.yml exposed on its own today (accidental commit, partial backup, pasted config) is instantly usable, however, when it is encrypted against the SSH key, it wouldn't be. And if that key happens to be passphrase-protected, exposure of the private key alone still doesn't hand over the token or re-auth capability, the passphrase gates both. Worth pairing with a one-time setup prompt to add the key path to the user's global git ignore (core.excludesFile), since that closes the realistic case of someone managing a dotfiles repo later running git add . and sweeping the key in without noticing. That'd be an opt-in prompt rather than something gh does silently, since editing global git config is a bigger action than gh normally takes.
HTTPS/device-flow users have no equivalent to reuse. The device flow (gh auth login over HTTPS shows a short one-time code, you enter it at github.com/login/device) never generates any key material, it just hands back a bearer token at the end. There's nothing already sitting on disk that gh could encrypt against, unlike the SSH case. The only way to add real protection here is to generate a dedicated keypair specifically for this, and for it to mean anything, it needs its own passphrase or recovery phrase, the same model crypto wallets use for a locally-stored private key. That's a real onboarding step gh doesn't currently ask for, so it's the more invasive of the two paths, but it's also the only honest option: anything less (an unprotected generated key sitting next to the file it's meant to protect) doesn't actually add security, it just adds a decoding step.
Option 1 is fully self-contained for any WSL user, since it can install gh.exe itself if it's missing. Option 2 works everywhere Linux keyring is unavailable, not just WSL, and for SSH users specifically it can be entirely transparent, but the HTTPS path genuinely needs a new interactive step that doesn't exist now.
I'll be more than happy to draft up a PR but this issue does not have the help-wanted label.
Reacted by rmscode, bobsdacool, Luca, Wiktor Bielakow and Abdenour Tadjer
Just to throw in that this issue is not just for WSL but also exists for headless containers running linux also. On this basis I don't think using windows systems for credential storage is worthwhile.
For instance, I have a jupyterhub instance, that uses images based on ubuntu noble, and users cannot use encrypted verification within jupyterhub.
This is really bad as in a professional context I know people that have to work with patient data in the same environment. It is an easy mistake for them to wind up storing their credentials in plain text. It most definitely happens.
Sure the whole thing sits behind a vpn and a suite of other security items, but I really think this should be addressed by github so it is platform agnostic.
@bobsdacool I got curious about how I can secure the gh token in your case and came up with a confirmed workaround on ubuntu:noble.
Why this happens (quick recap)
As I explained before, gh stores tokens through a library that looks for a Secret Service on D-Bus, normally provided by gnome-keyring on a real desktop. On a normal Ubuntu desktop, logging in triggers PAM, which unlocks your keyring automatically and starts a session bus as part of that login. A JupyterHub single-user container never runs a desktop login at all, so none of that happens, no session bus, and nothing to unlock the keyring even if you get one running manually.
Why manually starting gnome-keyring-daemon won't fix it
There is an old trick for headless Docker containers: start a D-Bus session with dbus-run-session, then pipe a blank password into gnome-keyring-daemon --unlock to create and unlock a login keyring without needing PAM.
This no longer works on modern systems, including Ubuntu Noble. Since GLib 2.70, a security hardening change explicitly blocksgnome-keyring-daemon from spawning or connecting to a D-Bus session bus unless it's inside a real systemd --user session, something a JupyterHub single-user container doesn't run. So this trick has been made useless since the security hardening update.
The actual fix: skip hosts.yml entirely by hooking in GH_TOKEN
gh's own documentation describes this as the supported path for headless/automation use, so it is not a workaround per se. If a GH_TOKEN secret environment variable is set, gh uses it directly and never runs the interactive login flow gh auth login that writes to ~/.config/gh/hosts.yml. Since no file gets written, there is nothing to leak, encrypt, or accidentally commit.
The workaround
1. Generate a scoped token per tenant
Use a fine-grained personal access token, scoped to only the repos and permissions (to clone, push, and pull, you need to add read/write permissions for the "Contents" permission) that tenant actually needs, with an expiry date set. Do this per tenant, not once for the whole JupyterHub instance, since a shared token defeats the entire point of per-tenant accountability.
2. Store the token for each tenant where your spawner can pull it from at spawn time
DockerSpawner or classic multi-user Linux: JupyterHub's Hub process runs as root either way, to spawn containers or switch to other Unix users, so the natural equivalent of a Kubernetes Secret here is a root-owned file per tenant that only JupyterHub can read, not even the tenant it belongs to.
Option A, root-owned files, zero extra dependencies:
sudo mkdir -p /etc/jupyterhub/secrets/gh-tokens
sudo chmod 700 /etc/jupyterhub/secrets/gh-tokens
sudo chown root:root /etc/jupyterhub/secrets/gh-tokens
# Per tenant, run once when you issue their token:echo"github_pat_xxxxxxxx"| sudo tee /etc/jupyterhub/secrets/gh-tokens/TENANTUSERNAME > /dev/null
sudo chmod 600 /etc/jupyterhub/secrets/gh-tokens/TENANTUSERNAME
sudo chown root:root /etc/jupyterhub/secrets/gh-tokens/TENANTUSERNAME
Option B, pass, the standard GPG-backed Unix password store, if you want the tokens encrypted at rest (RECOMMENDED):
# One-time setup: generate a GPG key for the Hub, then initialize the store
gpg --full-generate-key # note the key ID this produces
pass init <the-gpg-key-id># Per tenant:
pass insert github-tokens/TENANTUSERNAME
One caveat with pass: decrypting needs the GPG private key unlocked, so unless gpg-agent is already running and unlocked on JupyterHub's host, the first spawn after a JupyterHub host restart may need a passphrase entered once, gpg-agent caches the unlock after that.
3. Inject it as GH_TOKEN via a spawn hook, not baked into config
This is the exact mechanism tested: the hook runs before the container starts, so GH_TOKEN is present from the moment the session begins, pulled from a secret store rather than committed anywhere.
4. Wire git to use the same token, not just gh
GH_TOKEN alone authenticates gh, but plain git clone/git push won't pick it up on their own. Bake this into your single-user base image so it's already configured when a session starts:
Now both gh and git authenticate from the same environment variable, which I tested and confirmed, git clone succeeded through this path with no separate prompt.
5. Verify nothing lands on disk
Inside a spawned JupyterHub session:
gh auth status
ls -la ~/.config/gh
The second command should come back empty, or the directory shouldn't exist at all. If hosts.yml shows up, GH_TOKEN isn't actually set for that session, check the spawn hook ran for that specific user.
What this does and doesn't protect against
This closes the specific gap the plaintext warning is about: a credential sitting in a file that outlives the session and can leak through channels unrelated to an active attacker, an accidental commit, a backup snapshot, an idle container's filesystem being browsed later, or an automated secret scanner catching a recognizable token prefix. Since the token is never written to the session's filesystem and the container is destroyed on shutdown, none of those risk vectors can happen while the session is running, and once the session ends, there's no artifact left for anyone to purposely or accidentally discover at all.
It is important to note that the injected secret environment variable does not hide the token from the tenant's own gh and git session (expected, since it's their scoped credential), and it does not hide it from any admin with access to the Docker or Kubernetes orchestration layer itself via docker inspect or kubectl get pod -o yaml (also expected, since they're the ones provisioning it in the first place). BUT, and a big BUT, that is innately way more secure and a significantly smaller attack surface. I would like to add that token expiry is what limits the damage if a token does leak in case of some massive oversight (such as an unauthorized, malicious actor gaining access to the host machine), so take full advantage of scoping (the fine-tuning part of repos and permissions of a personal access token) and rotation (that is by setting an expiry date) as part of the fix, not as optional extras.
Reacted by Khalifa
Reacted by Caleb Norton, Y. Meyer-Norwood, Dylan Fanner, supat and Seth R. Johnson
Posting this since this issue keeps coming back for WSL users. I laid out both a working interim fix and a thought-out actual permanent one.
@xalifaaa, the issue isn't that there is a workaround, even if it is permanent; the issue is that the credentials are stored in plaintext in the first place.
What I think everyone is this thread can agree on, is that starting with the next version, gh should never put the plain text credentials into the filesystem. Existing config files should be supported for existing users, nobody is asking to break backwards compatibility, but it is unreasonable and irresponsible to expect every future user of gh to know about this thread, and know whether or not their keychain is properly configured, before authentication.
By the time plaintext creds are on the filesystem, it is already too late.
Reacted by Gianni De Fabritiis and Seth R. Johnson
Posting this since this issue keeps coming back for WSL users. I laid out both a working interim fix and a thought-out actual permanent one.
@xalifaaa, the issue isn't that there is a workaround, even if it is permanent; the issue is that the credentials are stored in plaintext in the first place.
That's why I laid out two permanent solutions to the issue alongside the workaround 🤔
but it is unreasonable and irresponsible to expect every future user of gh to know about this thread, and know whether or not their keychain is properly configured, before authentication.
Upon seeing ! Authentication credentials saved in plain text after authenticating, gh users will search for a solution, even a temporary one, and that's what I provided for WSL and headless/containerized users, alongside proposed permanent solutions to the core issue.
By the time plaintext creds are on the filesystem, it is already too late.
"Too late" implies the exposure is irreversible, but it isn't: SSH keys, HTTPS bearer tokens, and fine-grained personal access tokens can all be revoked at any time from GitHub account settings, which kills access immediately in case of environment compromise. And if a user's Windows or Kubernetes/Docker environment is compromised badly enough that an attacker can read plaintext files off disk, that attacker almost certainly also has access to local unencrypted password manager files, browser cookie stores, SSH private keys, and every other secret on that machine.
The temporary workarounds have genuine value: They stop the gh token from sitting in a plaintext file that could leak via incorrect git configurations that push hidden files, backups, disk imaging, cloud file sync tools, or another local user with read access on a shared machine.
This is still an issue; I today found a plaintext copy committed to my thankfully "private" rc files repo because one of the servers I use didn't bother to warn me about saving as plaintext.
This is still an issue; I today found a plaintext copy committed to my thankfully "private" rc files repo because one of the servers I use didn't bother to warn me about saving as plaintext.
There is a working workaround above that I posted. I also proposed a permanent solution but haven't heard back from the maintainers.
I'm on a PTY SSH session. The gh auth command says NOTHING about plain text.
$ gh auth refresh
! First copy your one-time code: XXXX-XXXX
Press Enter to open https://github.com/login/device in your browser...
# Here it tries to open lynx for fuck's sake, so I cancel, copy the code and open in another browser
Exiting via interrupt: 2
✓ Authentication complete.
$ gh --version
gh version 2.96.0
$ grep oauth_token .config/gh/hosts.yml
# and it shows up :(
Yes I can try to forward the token over ssh, but yuck.
Activity
3f6a commentedon Aug 16, 2024
Any updates here?
wolfthegreat commentedon Sep 5, 2024
this is so stupid, even git has integrated credential manager with it's DPAPI
b4shful commentedon Oct 31, 2024
what the fuck
This should not be the default, in fact there's an
--insecure-storageflag for the people who want to save in plaintext. I'd say that if it can't use the keyring for some reason, it should error out and tell the user what went wrong.Under no circumstances should it save a token in plaintext when the user hasn't asked for this.
b4shful commentedon Oct 31, 2024
@williammartin Is there anything in the works about this?
I have seen this happen to a lot of people (including myself) time and time again.
Oftentimes people are trying to do the right thing, they install and set up a keyring/credential store and even point github cli to it, and then they run
gh auth loginagain, only to be told the credentials were saved in plain text.Then it becomes a frantic search for where the credentials got saved to delete the insecure config file.
I'd argue that the default behaviour should NEVER be to save in plain text, the presence of the
--insecure-storageflag should be the ONLY reason that credentials/tokens get saved in plain text. If gh-cli can't work with the keyring/cred store, then it should error out and give the user some information so they could perhaps fix it. Because right now it's very difficult to figure out what's going wrong, and this in turn makes it difficult to use GH CLI with proper credential security.(also PS: apologies, please excuse my swearing in the previous comment - it was my genuine reaction upon seeing that CLI message. Although given the times we live in, perhaps this reaction to security issues is justified!)
williammartin commentedon Oct 31, 2024
Hihihi.
Firstly, @3f6a, I'm sorry about this going unanswered, there was an extended period where we were dealing with a lot of team churn, and some things got lost. In terms of your original question, we try to use the credential manager as provided over dbus by the zalando go-keyring module. Right now I have no idea why your keyring isn't being used because unfortunately, if there is an error it suppressed and we move on to insecure storage. Why do we do that? Well it relates to:
So just a little history before we figure out what to do next: Originally, the GitHub CLI only supported writing the token insecurely to a file. In fact it was only February 2023 that
ghbegan using keyrings for secure storage. When this was added, it was opt-in via--secure-storagebecause the team didn't want to break existing workflows. Later, this was made the default and the previous insecure behaviour was opt in via--insecure-storage. The fallback behaviour remained in place however, for the interactive flow we can debate that value of that (it might even be negative), for the non-interactive flow (i.e.--with-token), no longer falling back is a breaking change.So, hopefully with that context you can understand why the situation is as it is.
What Next?
Non-Interactivity
It's don't think it's acceptable to break scripts out there that are calling
gh auth login --with-tokenon environments that don't have a keyring available. However, we can consider some mitigations and nudges:--no-insecure-fallbackoption for users who really really don't want this to happenno_insecure_fallbackconfig option forgh configthat makes it persistentInteractivity
With the interactive flows we have a bit more flexibility around changing behaviour. I think there are three paths we could take here, each with a different tradeoff of safety vs convenience.
--no-insecure-fallback/no_insecure_fallbackidea from aboveAs above, we could allow users to opt-in to turning this off. The downside of course is that this is opt-in, which even with the warning, still ends up with your token potentially being written to disk.
Re-prompt
On a failure to use the keyring we could offer a prompt along the lines of:
This is informative and allows for immediate recovery, which is nice since the user has probably gone through a whole oauth dance already.
Error
On a failure to use the keyring we could error out with the reason.
This might be immediately annoying to a user, but it's a pretty big nudge that they need to start using
--insecure-storagefrom now on, which should be high friction.My Recommendation
I think I would probably favour all the non-interactive options, and the last of the interactive options (erroring).
Both the re-prompt and error options would also help provide clarity to why keyring operations are failing, since the error is currently suppressed. This isn't the first time this has happened, I vaguely recall creating a branch specifically for someone to get this debug info, which is super lame.
It is pretty late here so I definitely need to sleep on this, and get input from the rest of the team. What do you think though?
b4shful commentedon Oct 31, 2024
Hi @williammartin thanks for this!
I've went down a rabbit hole... on my personal machine, I run a window manager (Hyprland) which needs some prior configuration, but does work with GNOME Keyring. I've gotten used to that one, it can be hard to tell when it's configured right but I've done it enough to know by now.
However, the environment I'm currently working on, a headless Ubuntu 24.04 Server, is just running through a tty, no display manager, no desktop environment, and oh boy has it been a PAIN. I can't for the life of me get GNOME Keyring to work for a series of incredibly dumb reasons and despite even dumber workarounds.
zalando/go-keyring's README says:
It's the default in most desktop environments, and most of them use GNOME Keyring, the ones that don't probably have no choice but to do things GNOME's way. And what about when you're not using a desktop environment?
Seriously though, even if you get GNOME Keyring working on a headless install, it won't create the default
loginkeyring, and the reason why is (I wish I was kidding) it tries to use GNOME's prompter service for a password prompt, even if you give it the password via stdin! You also can't really manually create it either, as the only working way seems to be Seahorse, which is a GUI app.It seems like a cascading series of decisions (go-keyring needing secret service (doable) and the login keyring (less doable), and GNOME Keyring being unusable on a proper headless (tty) install) which end up combining in an unfortunate way to make it impossible without a graphical environment. It's quite ironic considering the prevalence of headless and DE-less environments using git and gh.
I see the tricky situation you guys have here, I will come back to this with more input later, but for now it seems I have even more problems. This message is just background information, but it might be somewhat informative and fall under the category of "user input". At a later date I'll come back and give my thoughts specifically on the gh cli side.
Have a wonderful rest, goodnight! <3
For me, back to trying every single
org.freedesktop.secretsprovider to see if any of them actually work.3f6a commentedon Nov 1, 2024
@williammartin Let me know if there are any tests I can do to give further information !
Note that the Ubuntu servers I'm using have graphical Ubuntu installed, though I connect to them with ssh (without GUI). Also, probably they do not have the latest versions of Ubuntu.
Naqibullah380 commentedon Nov 1, 2024
williammartin commentedon Nov 29, 2024
@3f6a sorry for the long delay here.
If you build https://github.com/cli/cli/tree/wm/auth-debug from source and run
./bin/gh auth debugit'll try to grab the token for github.com from the keyring, and we'll likely see a timeout, or a useful error.Lemme know how it goes 🤞
11 remaining items
babakks commentedon Jan 5, 2026
Thanks for the investigation, @trinitronx! 🙏
Yeah, it looks like this is an issue with the
go-keyringmodule:futuremotiondev commentedon Feb 10, 2026
Is there a workaround for this so I can authenticate via non-plaintext (
~/.config/gh/hosts.yml) on Ubuntu in WSL2? I tried addingexport GITHUB_TOKEN=$(gh.exe auth token)to my.bashrc(and yes,gh.exeis in$PATH), but that didn't work (AI suggested that).How can I properly authenticate securely on Ubuntu in WSL2? Does anyone have a way?
williammartin commentedon Feb 11, 2026
I think if you are on WSL you'll probably need some combo of:
bobsdacool commentedon Jun 19, 2026
This is really quite frustrating.
I use jupyterlab built on ubuntu noble for my work. Having github store credentials in plain text, with no recourse as keychain isn't supported on the headless containers is really really really really bad.
If someone managed to probe the server for those credentials it would be a complete disaster. This is a really major issue!
xalifaaa commentedon Jul 9, 2026
Posting this since this issue keeps coming back for WSL users. I laid out both a working interim fix and a thought-out actual permanent one.
Why this issue happens
ghstores tokens throughgithub.com/zalando/go-keyring, which looks for a Secret Service on D-Bus, normally provided bygnome-keyringon a real desktop. On a normal Ubuntu desktop, logging in triggers PAM, which unlocks your keyring automatically and a session bus gets started as part of that login. WSL never runs a desktop login at all, so none of that happens. There's no session bus, and nothing to unlock the keyring even if you get one running.This is also why installing
libsecret-1-0 libsecret-1-dev libsecret-common gnome-keyring gnome-keyring-pkcs11 seahorsedoesn't actually fix it. You can startgnome-keyring-daemonand export the D-Bus variables by hand, but the default collection never gets unlocked, since that unlock normally happens as a side effect of a login event that WSL never fires. So the Secret Service is either not advertised at all, or advertised and stuck locked, andghjust falls back to plaintext either way.The interim workaround fix
( @3f6a , @bobsdacool , @futuremotiondev , @rmscode , @AlexKeySmith , @wolfthegreat , @b4shful )
Since recreating desktop login infrastructure inside a headless shell is more trouble than it's worth, the simplest fix right now is to skip Linux keyring entirely and let the Windows side of GitHub CLI handle storage instead, since
gh.exealready has working credential storage through Windows.1. Check that GitHub CLI is installed on Windows
If that fails, install GitHub CLI on your Windows machine from your WSL instance:
powershell.exe -Command "winget install --id GitHub.cli"2. Clean up the Linux side
(If you already tried the
libsecret/gnome-keyringroute, you can remove those too withsudo apt remove libsecret-1-0 libsecret-1-dev libsecret-common gnome-keyring gnome-keyring-pkcs11 seahorse -y)3. Alias
ghtogh.exebefore authenticating4. Authenticate
Pick SSH as the protocol and "Login with a web browser" as the auth method. Once it finishes, you'll see
✓ Authentication complete.5. Verify
You should see you're logged in through your Windows environment, and
~/.config/ghstays empty since everything lives on the Windows filesystem instead.6. Set up a WSL-native SSH key for
git pushgh auth loginin step 4 generates its SSH key on the Windows side (C:\Users\...\.ssh\), since that's wheregh.exeis actually running. That's fine forghitself, but nativegitcommands run from WSL (git push,git pull, etc.) look in WSL's own~/.ssh, sogitwon't find that key andgit pushwill fail withPermission denied (publickey).The simplest fix is to copy the key over from your Windows machine to WSL:
This way
git pushin WSL will authenticate with the same keyghused to authenticate with GitHub in step 4.A permanent fix instead of a manual workaround
( @williammartin , @babakks )
Two directions that could actually close this for everyone instead of requiring the steps above:
Option 1: Auto re-exec into
gh.exewhen running under WSL. A Linux binary has no way to call Windows Credential Manager directly, the two operating systems don't share that kind of function-call boundary. The only way to actually reach Windows Credential Manager from inside WSL is to have an actual Windows program do the work, there's no shortcut around launching one. Concretely, here's whatghcould do automatically:/proc/version(or/proc/sys/kernel/osrelease) for the stringmicrosoft, which is the standard, well-documented way any Linux program detects it's running under WSL.gh.exeis reachable onPATH. This already works out of the box by default, since WSL automatically exposes Windows'PATHinside the Linux shell unless a user has specifically disabled that inwsl.conf.ghcould shell out topowershell.exe -Command "winget install --id GitHub.cli"itself and then re-checkPATH, the same way it would callgh.exein the next step. Nothing about the WSL boundary limits this to launchinggh.exespecifically, any Windows executable is reachable the same way.gh.exeis confirmed present (whether it was already there or just installed), re-run the same command asgh.exeinstead, passing through the terminal input/output directly so the browser login flow and any prompts still work normally, then exit with whatever exit codegh.exereturned.In plain terms, this is exactly what the manual
alias gh="gh.exe"workaround does today, just built intoghitself. It doesn't touch the keyring library at all, it just hands the whole job to the Windows binary.Option 2: Add an encrypted fallback, built directly into gh. gh depends on
github.com/zalando/go-keyring, which only wraps the three OS-native stores (macOS Keychain, Linux Secret Service, Windows Credential Manager). It has no fallback backend of any kind, so this would be new code gh writes itself. In gh's own code, wherever it currently catches "keyring unavailable" and falls through to writing the token in plaintext, that's where this function would slot in instead, encrypting the token before it's written under~/.config/ghrather than writing it raw. What key protects that encryption should depend on which protocol the user picked duringgh auth login, since SSH and HTTPS leave gh in genuinely different positions here:age(and its Go library,filippo.io/age, with theagesshpackage) already supports encrypting to an existingssh-ed25519/ssh-rsapublic key and decrypting with the matching private key file, a documented existing pattern, not something new to invent. gh could reuse the same SSH key the user authenticated with, reading it on demand with no prompt. The fix here is narrow and specific:hosts.ymlexposed on its own today (accidental commit, partial backup, pasted config) is instantly usable, however, when it is encrypted against the SSH key, it wouldn't be. And if that key happens to be passphrase-protected, exposure of the private key alone still doesn't hand over the token or re-auth capability, the passphrase gates both. Worth pairing with a one-time setup prompt to add the key path to the user's global git ignore (core.excludesFile), since that closes the realistic case of someone managing a dotfiles repo later runninggit add .and sweeping the key in without noticing. That'd be an opt-in prompt rather than something gh does silently, since editing global git config is a bigger action than gh normally takes.gh auth loginover HTTPS shows a short one-time code, you enter it atgithub.com/login/device) never generates any key material, it just hands back a bearer token at the end. There's nothing already sitting on disk that gh could encrypt against, unlike the SSH case. The only way to add real protection here is to generate a dedicated keypair specifically for this, and for it to mean anything, it needs its own passphrase or recovery phrase, the same model crypto wallets use for a locally-stored private key. That's a real onboarding step gh doesn't currently ask for, so it's the more invasive of the two paths, but it's also the only honest option: anything less (an unprotected generated key sitting next to the file it's meant to protect) doesn't actually add security, it just adds a decoding step.Option 1 is fully self-contained for any WSL user, since it can install
gh.exeitself if it's missing. Option 2 works everywhere Linux keyring is unavailable, not just WSL, and for SSH users specifically it can be entirely transparent, but the HTTPS path genuinely needs a new interactive step that doesn't exist now.I'll be more than happy to draft up a PR but this issue does not have the
help-wantedlabel.bobsdacool commentedon Jul 9, 2026
I'm not a developer but just my tuppence.
Just to throw in that this issue is not just for WSL but also exists for headless containers running linux also. On this basis I don't think using windows systems for credential storage is worthwhile.
For instance, I have a jupyterhub instance, that uses images based on ubuntu noble, and users cannot use encrypted verification within jupyterhub.
This is really bad as in a professional context I know people that have to work with patient data in the same environment. It is an easy mistake for them to wind up storing their credentials in plain text. It most definitely happens.
Sure the whole thing sits behind a vpn and a suite of other security items, but I really think this should be addressed by github so it is platform agnostic.
xalifaaa commentedon Jul 10, 2026
@bobsdacool I got curious about how I can secure the
ghtoken in your case and came up with a confirmed workaround onubuntu:noble.Why this happens (quick recap)
As I explained before,
ghstores tokens through a library that looks for a Secret Service on D-Bus, normally provided bygnome-keyringon a real desktop. On a normal Ubuntu desktop, logging in triggers PAM, which unlocks your keyring automatically and starts a session bus as part of that login. A JupyterHub single-user container never runs a desktop login at all, so none of that happens, no session bus, and nothing to unlock the keyring even if you get one running manually.Why manually starting
gnome-keyring-daemonwon't fix itThere is an old trick for headless Docker containers: start a D-Bus session with
dbus-run-session, then pipe a blank password intognome-keyring-daemon --unlockto create and unlock a login keyring without needing PAM.This no longer works on modern systems, including Ubuntu Noble. Since GLib 2.70, a security hardening change explicitly blocks
gnome-keyring-daemonfrom spawning or connecting to a D-Bus session bus unless it's inside a realsystemd --usersession, something a JupyterHub single-user container doesn't run. So this trick has been made useless since the security hardening update.The actual fix: skip
hosts.ymlentirely by hooking inGH_TOKENgh's own documentation describes this as the supported path for headless/automation use, so it is not a workaround per se. If aGH_TOKENsecret environment variable is set,ghuses it directly and never runs the interactive login flowgh auth loginthat writes to~/.config/gh/hosts.yml. Since no file gets written, there is nothing to leak, encrypt, or accidentally commit.The workaround
1. Generate a scoped token per tenant
Use a fine-grained personal access token, scoped to only the repos and permissions (to clone, push, and pull, you need to add read/write permissions for the "Contents" permission) that tenant actually needs, with an expiry date set. Do this per tenant, not once for the whole JupyterHub instance, since a shared token defeats the entire point of per-tenant accountability.
2. Store the token for each tenant where your spawner can pull it from at spawn time
Kubernetes (KubeSpawner):
DockerSpawner or classic multi-user Linux: JupyterHub's Hub process runs as root either way, to spawn containers or switch to other Unix users, so the natural equivalent of a Kubernetes Secret here is a root-owned file per tenant that only JupyterHub can read, not even the tenant it belongs to.
Option A, root-owned files, zero extra dependencies:
Option B,
pass, the standard GPG-backed Unix password store, if you want the tokens encrypted at rest (RECOMMENDED):One caveat with
pass: decrypting needs the GPG private key unlocked, so unlessgpg-agentis already running and unlocked on JupyterHub's host, the first spawn after a JupyterHub host restart may need a passphrase entered once,gpg-agentcaches the unlock after that.3. Inject it as
GH_TOKENvia a spawn hook, not baked into configKubeSpawner, in
jupyterhub_config.py:DockerSpawner or classic multi-user Linux, in
jupyterhub_config.py, matching Option A above:If you went with Option B (
pass) instead, swapget_token_for_userfor:This is the exact mechanism tested: the hook runs before the container starts, so
GH_TOKENis present from the moment the session begins, pulled from a secret store rather than committed anywhere.4. Wire
gitto use the same token, not justghGH_TOKENalone authenticatesgh, but plaingit clone/git pushwon't pick it up on their own. Bake this into your single-user base image so it's already configured when a session starts:Now both
ghandgitauthenticate from the same environment variable, which I tested and confirmed,git clonesucceeded through this path with no separate prompt.5. Verify nothing lands on disk
Inside a spawned JupyterHub session:
gh auth status ls -la ~/.config/ghThe second command should come back empty, or the directory shouldn't exist at all. If
hosts.ymlshows up,GH_TOKENisn't actually set for that session, check the spawn hook ran for that specific user.What this does and doesn't protect against
This closes the specific gap the plaintext warning is about: a credential sitting in a file that outlives the session and can leak through channels unrelated to an active attacker, an accidental commit, a backup snapshot, an idle container's filesystem being browsed later, or an automated secret scanner catching a recognizable token prefix. Since the token is never written to the session's filesystem and the container is destroyed on shutdown, none of those risk vectors can happen while the session is running, and once the session ends, there's no artifact left for anyone to purposely or accidentally discover at all.
It is important to note that the injected secret environment variable does not hide the token from the tenant's own
ghandgitsession (expected, since it's their scoped credential), and it does not hide it from any admin with access to the Docker or Kubernetes orchestration layer itself viadocker inspectorkubectl get pod -o yaml(also expected, since they're the ones provisioning it in the first place). BUT, and a big BUT, that is innately way more secure and a significantly smaller attack surface. I would like to add that token expiry is what limits the damage if a token does leak in case of some massive oversight (such as an unauthorized, malicious actor gaining access to the host machine), so take full advantage of scoping (the fine-tuning part of repos and permissions of a personal access token) and rotation (that is by setting an expiry date) as part of the fix, not as optional extras.norwd commentedon Jul 10, 2026
@xalifaaa, the issue isn't that there is a workaround, even if it is permanent; the issue is that the credentials are stored in plaintext in the first place.
What I think everyone is this thread can agree on, is that starting with the next version,
ghshould never put the plain text credentials into the filesystem. Existing config files should be supported for existing users, nobody is asking to break backwards compatibility, but it is unreasonable and irresponsible to expect every future user ofghto know about this thread, and know whether or not their keychain is properly configured, before authentication.By the time plaintext creds are on the filesystem, it is already too late.
xalifaaa commentedon Jul 15, 2026
That's why I laid out two permanent solutions to the issue alongside the workaround 🤔
Upon seeing
! Authentication credentials saved in plain textafter authenticating,ghusers will search for a solution, even a temporary one, and that's what I provided for WSL and headless/containerized users, alongside proposed permanent solutions to the core issue."Too late" implies the exposure is irreversible, but it isn't: SSH keys, HTTPS bearer tokens, and fine-grained personal access tokens can all be revoked at any time from GitHub account settings, which kills access immediately in case of environment compromise. And if a user's Windows or Kubernetes/Docker environment is compromised badly enough that an attacker can read plaintext files off disk, that attacker almost certainly also has access to local unencrypted password manager files, browser cookie stores, SSH private keys, and every other secret on that machine.
The temporary workarounds have genuine value: They stop the
ghtoken from sitting in a plaintext file that could leak via incorrect git configurations that push hidden files, backups, disk imaging, cloud file sync tools, or another local user with read access on a shared machine.ariadnavigo commentedon Aug 16, 2026
What I don't understand is why lightweight org.freedesktop.secrets providers like oo7 don't work either...
EMJmaker commentedon Sep 13, 2026
sethrj commentedon Nov 13, 2026
This is still an issue; I today found a plaintext copy committed to my thankfully "private" rc files repo because one of the servers I use didn't bother to warn me about saving as plaintext.
xalifaaa commentedon Nov 13, 2026
There is a working workaround above that I posted. I also proposed a permanent solution but haven't heard back from the maintainers.
sethrj commentedon Nov 13, 2026
@xalifaaa
I'm on a PTY SSH session. The
gh authcommand says NOTHING about plain text.Yes I can try to forward the token over ssh, but yuck.