Skip to content

gh auth saves plain text token, even though I have keyring #8954

Open

Description

@3f6a

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

Activity

  1. added
    bugSomething isn't working
    on Apr 10, 2024
  2. 3f6a commented on Aug 16, 2024

    @3f6a
    Author

    Any updates here?

  3. wolfthegreat commented on Sep 5, 2024

    @wolfthegreat

    this is so stupid, even git has integrated credential manager with it's DPAPI

  4. b4shful commented on Oct 31, 2024

    @b4shful
    ✓ 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.

  5. b4shful commented on Oct 31, 2024

    @b4shful

    @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!)

  6. williammartin commented on Oct 31, 2024

    @williammartin
    Member

    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:

    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 gh began 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?

  7. b4shful commented on Oct 31, 2024

    @b4shful

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

  8. 3f6a commented on Nov 1, 2024

    @3f6a
    Author

    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.

  9. Naqibullah380 commented on Nov 1, 2024

    @Naqibullah380
  10. williammartin commented on Nov 29, 2024

    @williammartin
    Member

    @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 debug it'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. 11 remaining items

  12. babakks commented on Jan 5, 2026

    @babakks
    Member

    Thanks for the investigation, @trinitronx! 🙏

    Yeah, it looks like this is an issue with the go-keyring module:

    It's expected that the default collection login exists in the keyring, because it's the default in most distros.

  13. futuremotiondev commented on Feb 10, 2026

    @futuremotiondev

    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?

  14. williammartin commented on Feb 11, 2026

    @williammartin
    Member

    How can I properly authenticate securely on Ubuntu in WSL2? Does anyone have a way?

    I think if you are on WSL you'll probably need some combo of:

    sudo apt list libsecret-1-0 libsecret-1-dev libsecret-common gnome-keyring gnome-keyring-pkcs11 seahorse
    
  15. bobsdacool commented on Jun 19, 2026

    @bobsdacool

    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!

  16. xalifaaa commented on Jul 9, 2026

    @xalifaaa

    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:

    powershell.exe -Command "winget install --id GitHub.cli"

    2. Clean up the Linux side

    rm -rf ~/.config/gh/*
    sudo apt remove gh -y
    sudo rm -f /etc/apt/sources.list.d/github-cli.list
    sudo rm -f /usr/share/keyrings/githubcli-archive-keyring.gpg
    sudo apt update

    (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)

    3. Alias gh to gh.exe before authenticating

    echo 'alias gh="gh.exe"' >> ~/.bashrc
    source ~/.bashrc

    4. Authenticate

    gh auth login

    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:

    mkdir -p ~/.ssh
    cp /mnt/c/Users/[YourWindowsUser]/.ssh/id_ed25519 ~/.ssh/
    cp /mnt/c/Users/[YourWindowsUser]/.ssh/id_ed25519.pub ~/.ssh/
    chmod 700 ~/.ssh
    chmod 600 ~/.ssh/id_ed25519
    chmod 644 ~/.ssh/id_ed25519.pub

    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.

  17. bobsdacool commented on Jul 9, 2026

    @bobsdacool

    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.

  18. xalifaaa commented on Jul 10, 2026

    @xalifaaa

    @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 blocks gnome-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

    Kubernetes (KubeSpawner):

    kubectl create secret generic gh-token-TENANTUSERNAME \
      --namespace=jupyterhub \
      --from-literal=token=github_pat_xxxxxxxx

    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

    KubeSpawner, in jupyterhub_config.py:

    async def inject_gh_token(spawner):
        username = spawner.user.name
        spawner.env["GH_TOKEN"] = {
            "valueFrom": {
                "secretKeyRef": {
                    "name": f"gh-token-{username}",
                    "key": "token",
                }
            }
        }
     
    c.KubeSpawner.pre_spawn_hook = inject_gh_token

    DockerSpawner or classic multi-user Linux, in jupyterhub_config.py, matching Option A above:

    import os
     
    TOKEN_DIR = "/etc/jupyterhub/secrets/gh-tokens"
     
    def get_token_for_user(username):
        path = os.path.join(TOKEN_DIR, username)
        try:
            with open(path) as f:
                return f.read().strip()
        except FileNotFoundError:
            return None
     
    async def inject_gh_token(spawner):
        username = spawner.user.name
        token = get_token_for_user(username)
        if token:
            spawner.environment["GH_TOKEN"] = token
     
    c.DockerSpawner.pre_spawn_hook = inject_gh_token
    # Or for the built-in Spawner on a classic multi-user install:
    # c.Spawner.pre_spawn_hook = inject_gh_token

    If you went with Option B (pass) instead, swap get_token_for_user for:

    import subprocess
     
    def get_token_for_user(username):
        try:
            result = subprocess.run(
                ["pass", "show", f"github-tokens/{username}"],
                capture_output=True, text=True, check=True,
            )
            return result.stdout.strip()
        except subprocess.CalledProcessError:
            return None

    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:

    RUN git config --system --add credential.https://github.com.helper '' \
        && git config --system --add credential.https://github.com.helper "!/usr/bin/gh auth git-credential"

    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.

  19. norwd commented on Jul 10, 2026

    @norwd

    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.

  20. xalifaaa commented on Jul 15, 2026

    @xalifaaa

    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.

  21. ariadnavigo commented on Aug 16, 2026

    @ariadnavigo

    What I don't understand is why lightweight org.freedesktop.secrets providers like oo7 don't work either...

  22. EMJmaker commented on Sep 13, 2026

    @EMJmaker
  23. sethrj commented on Nov 13, 2026

    @sethrj

    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.

  24. xalifaaa commented on Nov 13, 2026

    @xalifaaa

    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.

  25. sethrj commented on Nov 13, 2026

    @sethrj

    @xalifaaa

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggh-authrelating to the gh auth commandneeds-triageneeds to be reviewed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions