Git on WSL Ubuntu 26.04
A new Ubuntu 26.04 under the Windows Subsystem for Linux (WSL), Microsoft’s way of running Linux distributions on Windows, comes with Git 2.53 installed and nothing else Git needs to work with GitHub: no name or email for commits, no key, and no GitHub command-line tool. Two PowerShell commands install the distribution, and one bash script fills in the rest. Afterwards there is a Linux user named claude and one SSH key, which GitHub accepts both for pushes and for verifying commit signatures. Git’s global configuration commits as the GitHub account and signs every commit with that key.
The script stops for the sudo password, a passphrase for the new key (typed twice), the same passphrase once more to load the key, and a press of Enter before the GitHub sign-in, which finishes in a browser on Windows. Every step is safe to repeat, so after a failure the script can be run again without duplicating anything.
The examples use the Linux user name claude. The script itself does not depend on it: it reads the user from $USER and $HOME.
Before you start#
- Windows 11, or Windows 10 version 2004 (build 19041) or later, which
wsl --installrequires. - A GitHub account, signed in to a browser on Windows.
- WSL itself. In PowerShell,
wsl --versionprints the version numbers of WSL, its kernel and Windows when it is installed. If it does not, open PowerShell with Run as administrator, install the WSL components alone, and restart Windows:
|
|
Install Ubuntu 26.04#
In PowerShell:
|
|
Ubuntu-26.04 is the name wsl --list --online gives the image. The plain Ubuntu entry in that list carries no version number, so naming the release pins it.
The first launch runs Ubuntu’s provisioning script, /usr/lib/wsl/wsl-setup, which asks three questions:
Create a default Unix user account:arrives with the Windows user name filled in, lower-cased. Erase it and typeclaude. The script accepts lowercase letters, digits,_and-, starting with a letter or_.- A password, twice, from
adduser. It becomes the passwordsudoasks for, and nothing on this page stores it. Would you like to opt-in to platform metrics collection (Y/n)?is Ubuntu Insights, which sends Canonical anonymous reports about the system. The answer is prefilled withy, andndeclines. The answer is saved in the Windows registry, and later Ubuntu installs read it from there instead of asking.
The provisioning script adds claude to the sudo group and writes default=claude under [user] in /etc/wsl.conf, which is what makes every later session log in as claude. The prompt then reads claude@<computer-name>:~$: WSL gives the distribution the Windows computer name as its hostname. wsl -d Ubuntu-26.04 opens it again later, and wsl --set-default Ubuntu-26.04 makes it the one a bare wsl opens.
Run the script#
Inside Ubuntu, create the file, paste the script below into it, and run it with bash:
|
|
Run it as a file instead of pasting the lines into the prompt. The script sets set -e, which ends the shell at the first failed command, and pasted into the prompt, that shell is the terminal. It also refuses to start under sudo or without a terminal. Under sudo it would set up root’s home instead of claude’s, and with no terminal to ask in, ssh-keygen quietly creates a key without a passphrase.
|
|
What it asks for#
- The
sudopassword, the one set at the username prompt. - A passphrase for the new key, twice. Pressing Enter both times creates a key without one; the passphrase and the agent covers what each choice costs.
- The passphrase again, when
ssh-addloads the key into the agent. A key without a passphrase loads without asking. Pressing Enter here stops the script with a message saying the key was not loaded, Ctrl+C stops it outright, and a rerun asks again. - Enter, for the GitHub sign-in.
gh2.103.2 printed this on 26.04:
|
|
The failure is expected. gh opens a browser through the first of xdg-open, x-www-browser, www-browser and wslview it finds, a fresh Ubuntu 26.04 has none of them, and the wslu package that used to supply wslview is not in the 26.04 archive. The code does reach Windows: gh copies it by default, and its clipboard library falls back to clip.exe, the Windows clipboard tool, which WSL makes runnable from Linux. Open https://github.com/login/device in a browser on Windows, paste the code, and authorize GitHub CLI. From there gh polls GitHub until the authorization arrives, and the script carries on by itself, ending with these lines:
|
|
The second line is there because terminals that were open when the script ran, including the one it ran in, do not know where the agent is, so with a passphrase-protected key every commit and push in them asks for it again.
What the script does#
The script was run on 23 October 2026 in a throwaway Ubuntu 26.04 distribution: from a clean install, again on top of its own result, and through its guards and recovery paths. Those were no terminal, sudo, Enter at the passphrase prompt, a sign-in without the key scopes, and an ssh -T that GitHub refused. The calls that need a real GitHub account were stubbed: the sign-in, the key uploads, the profile lookup and the final ssh -T. What gh and GitHub do at those steps comes from gh’s source and GitHub’s documentation.
SSH rather than Git Credential Manager#
Microsoft’s own guide to Git on WSL takes a different route: Git Credential Manager, installed with Git for Windows, which keeps an HTTPS token in the Windows Credential Manager and shares it with Git on the Windows side. That holds for HTTPS remotes only: the guide itself says Git Credential Manager “only works with HTTP(S) remotes”. It also notes that, by default, the Windows copy reads Git for Windows’ configuration rather than the distribution’s. Without Git for Windows, Git Credential Manager can be installed inside WSL instead, but it then runs as a Linux program and cannot use Windows’ credential storage. SSH needs nothing installed on Windows beyond WSL, and the same key signs the commits.
The GitHub CLI comes from GitHub’s repository#
Ubuntu 26.04’s own archive carries gh 2.46.0, released in March 2024. GitHub’s repository installed 2.103.2 when this script was tested in October 2026. The script follows GitHub’s instructions for Debian and Ubuntu with one addition: before apt trusts the downloaded signing keyring, sha256sum --check compares it with the checksum GitHub publishes alongside those instructions, so a substituted file stops the script. A key rotation stops it too, and the fix is the new checksum from the same page, docs/install_linux.md in the cli/cli repository.
One SSH key, registered twice#
The key is Ed25519, at ~/.ssh/id_ed25519. That is one of the file names ssh tries without being told, so no ~/.ssh/config is needed.
GitHub keeps two lists of keys for each account. A key on the authentication list lets git push and git fetch over SSH act as the account. A key on the signing list makes GitHub mark commits signed with it as Verified, when the commit’s email address is a verified address on the account or the account’s no-reply address, which GitHub’s signing instructions suggest for keeping the real one private. The same key can sit on both lists: GitHub’s documentation says “you can even upload your existing authentication key” as a signing key. gh ssh-key add --type picks the list. It checks the list first and reports Public key already exists on your account instead of failing, which is what makes the step safe to repeat.
On the Git side, gpg.format ssh switches signing from GPG to ssh-keygen -Y sign. GPG is the older way to sign commits, and it has expiry and revocation, which SSH keys do not. user.signingkey names the public key, and commit.gpgsign signs every commit without -S. Tags are left alone. tag.gpgsign would turn every git tag NAME into a signed annotated tag that opens an editor for its message, so a signed tag is made on purpose, with git tag -s. ~/.ssh/allowed_signers maps the email address to the key, and gpg.ssh.allowedSignersFile tells Git where it is. Git 2.53 needs both to check a signature locally. With the setting but no file, git log --show-signature reports a good signature without saying whose, beside Unable to open allowed keys file. With no setting at all, Git refuses to check, printing gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification and No signature. The script’s final check reads the same status, so it fails in either case.
The passphrase and the agent#
ssh-keygen asks for a passphrase and accepts an empty one. The difference is what the key file is worth on its own. Without a passphrase, the file is the credential: any process running as claude can copy it and push as the GitHub account from another machine. With one, a copy of the file is useless without the passphrase. That protects the key and not the account: the same process can read the gh token the script leaves in plain text, which can push over HTTPS and, until its key scopes are dropped, register a key of its own (signing in, and the token it leaves). In return for the passphrase, something has to hold the unlocked key, or every commit and push asks for it again. On 26.04, a key without a passphrase signs commits with no agent running. A key with one, with no agent and no terminal to ask in, fails with incorrect passphrase supplied to decrypt private key.
Ubuntu 26.04 already runs that something. Its openssh-client package installs ssh-agent.socket, a systemd user socket at $XDG_RUNTIME_DIR/openssh_agent (/run/user/1000/openssh_agent for the first user), which starts ssh-agent on the first connection. The socket unit announces that address only to systemd’s own environment, through systemctl --user set-environment. A WSL shell never learns it, and ssh-add there reports Could not open a connection to your authentication agent. The block the script appends to ~/.bashrc supplies the address and, in a shell with a terminal, loads the key unless the agent already holds it:
|
|
ssh-add -T asks the agent to sign with this particular key, where ssh-add -l would be satisfied by any key at all. The [ -t 0 ] test keeps a shell without a terminal, such as bash -i reading commands from a pipe, from having its input consumed as passphrase attempts. In a new terminal, Enter at the prompt skips loading the key. Ctrl+C skips it too, but also skips the rest of ~/.bashrc for that terminal.
The first terminal after the distribution starts asks for the passphrase, and later terminals find the key loaded and ask nothing. The key stays loaded until the distribution stops, at wsl --shutdown or wsl --terminate, or when WSL shuts down a distribution with nothing open in it. In testing, a terminal opened a few seconds after the last one closed found the key loaded. One opened less than half a minute later found the distribution restarted, and asked again.
Ubuntu’s ~/.bashrc returns early in a shell that is not interactive. A program started some other way, such as a wsl.exe -e command or a systemd service, has no SSH_AUTH_SOCK, and with a passphrase-protected key, its commits fail to sign. A tool like that needs SSH_AUTH_SOCK set to the same path, or a key without a passphrase.
GitHub’s host keys come from GitHub’s API#
The first ssh to a host it has not seen before stops to show the host key’s fingerprint and asks whether to trust it. The script skips that question. It writes GitHub’s keys into ~/.ssh/known_hosts from https://api.github.com/meta, the endpoint GitHub’s fingerprints page points to. The list arrives over HTTPS, so the trust rests on GitHub’s TLS certificate rather than on the first connection. Every run first removes any existing github.com entries with ssh-keygen -R, so an outdated entry in a ~/.ssh copied from another machine cannot outlive a key GitHub has replaced.
Signing in, and the token it leaves#
gh auth login --web uses GitHub’s device flow: gh shows a code, the browser enters it, and GitHub hands gh an OAuth token. --git-protocol ssh makes gh use SSH addresses for the repositories it clones. --skip-ssh-key stops gh offering to make and upload a key of its own, since the script does that itself. --scopes adds admin:public_key and admin:ssh_signing_key to the default scopes (repo, read:org and gist), because adding keys to the two lists needs them. When gh is already signed in, the script reads the scopes the token carries from the X-OAuth-Scopes header of an API response, and runs gh auth refresh if either key scope is missing. Without that check, a token from an earlier plain gh auth login fails at the key upload.
gh stores the token in the system credential store when there is one. A fresh WSL install has none: nothing on the session bus answers to org.freedesktop.secrets. So gh prints Authentication credentials saved in plain text and writes the token to ~/.config/gh/hosts.yml. Anything that reads that file can do what the scopes allow: full access to every repository the account can reach, and adding SSH keys to the account. Once the keys are on GitHub, the two key scopes can be dropped, at the cost of one more visit to the browser:
|
|
A later run of the script finds them missing and asks for them again, through the same browser step.
The Git configuration#
user.name is the name on the GitHub profile, or the login when the profile has none. user.email is the account’s ID-based no-reply address, <ID>+<login>@users.noreply.github.com, built from the account ID and login that gh api user returns. Commits made with it are attributed to the account, the real address stays out of every clone’s history, and, unlike the older <login>@users.noreply.github.com form, the attribution survives a change of username. Accounts that turned on email privacy before 18 July 2017 were given the older form, and GitHub’s email reference says that turning Keep my email address private off and on again issues the ID-based one. On an account that old, do that before relying on the address the script writes: the script’s own check verifies the signature locally and cannot tell whether GitHub recognizes the address. A rerun of the script sets the name and address again from the profile, replacing any edit made since. This sets up exactly one identity. Keeping two apart on one machine, with an identity per directory and a key per host, is covered in identity separation.
Three more settings change Git’s behaviour where an unconfigured 2.53 stops or guesses:
init.defaultBranch main. Unconfigured,git initnames the first branchmasterand prints a hint that the default becomesmainin Git 3.0. GitHub names a new repository’s default branchmain.pull.rebase true. Unconfigured,git pullon a branch that has diverged from its upstream stops withfatal: Need to specify how to reconcile divergent branches.With it, the pull replays the local commits on top of the fetched ones, which is the preference contributing to open source states for keeping a branch current.push.autoSetupRemote true. Unconfigured, the firstgit pushof a new branch fails withThe current branch topic has no upstream branch. With it, that push creates the branch on GitHub and tracks it.
Check#
|
|
ssh -T prints Hi <login>! You've successfully authenticated, but GitHub does not provide shell access. and exits with status 1 even then, because GitHub opens no shell. On a network that blocks port 22, it times out or is refused instead (the script gives up after 15 seconds). GitHub also accepts SSH on port 443 at ssh.github.com, which its documentation covers under Using SSH over the HTTPS port. That is a different host name, checked against a separate [ssh.github.com]:443 entry that the script does not write, so the first connection there asks about the host key; GitHub’s fingerprints page lists the values to compare. In any repository, git log --show-signature -1 on a new commit shows Good "git" signature for <ID>+<login>@users.noreply.github.com with ED25519 key SHA256:.... After a push, GitHub shows the same commit as Verified. ssh-keygen -lf ~/.ssh/id_ed25519.pub prints the fingerprint to compare against both lists at https://github.com/settings/keys.
Where repositories go#
Keep repositories under /home/claude, not under /mnt/c. Microsoft’s guidance is to store files in the Linux file system when working from a Linux command line, because access across the two file systems is slower. Windows reaches the same files at \\wsl.localhost\Ubuntu-26.04\home\claude, and explorer.exe . opens the current directory in File Explorer. Windows and Linux use different line endings by default, so a repository checked out by Git for Windows and then used from WSL can show every file as modified. Keeping each repository on one side avoids that.
Starting over#
Remove the key from both lists at https://github.com/settings/keys. The gh token stays valid on GitHub even after its file is gone, and gh auth logout does not change that, since it only deletes the local copy. Revoking means opening GitHub CLI at https://github.com/settings/applications and choosing Revoke Access, which revokes every token gh has been issued, on every machine. Then, from PowerShell:
|
|
Unregistering deletes the distribution’s whole file system, the private key included, and cannot be undone.
Further reading#
WSL itself has a short page under Microsoft. This one sits under Git because what it configures is Git’s identity and its access to GitHub, and WSL is where that runs.
- Microsoft, Install WSL and basic commands
- Microsoft, Get started using Git on WSL, the Git Credential Manager route
- GitHub CLI, Installing gh on Linux, with the keyring checksums
- GitHub Docs, About commit signature verification and GitHub’s SSH key fingerprints
- GitHub Docs, Using SSH over the HTTPS port
- GitHub Docs, email addresses reference, on the no-reply address
- Git,
gpg.ssh.allowedSignersFileingit-config