When you git push to an HTTPS remote, something has to supply a credential. Git does not manage one
itself — it asks a credential helper, and which helper answers determines whether your token is
held in an encrypted OS keychain, in memory for an hour, or in a plaintext file that any process
running as you can read.
Most people never choose. The helper was configured by an installer, a tutorial, or the first error message that made pushing work again.
The short answer
Section titled “The short answer”Find out what you are currently using:
git config --get-all credential.helperThen aim for one of these, in descending order of preference:
- An OS keychain via Git Credential Manager — Windows Credential Manager, macOS Keychain, or a Linux secret service.
cachewith a timeout — in memory only, forgotten on reboot.- Nothing at all, entering the token per operation. Tedious, and genuinely safe.
store— plaintext on disk. Acceptable only where nothing better exists and you understand what you are accepting.
And regardless of helper: use a fine-grained token, scoped to the repositories you need, with an expiry date.
How the credential flow works
Section titled “How the credential flow works”Git’s credential system is a small protocol rather than a feature, which is why it is worth understanding rather than configuring by copy-paste.
-
Git needs a credential for a URL. It builds a context: protocol, host, and — only if
credential.useHttpPathis set — the path. -
It runs each configured helper in turn with the
getoperation, passing the context on standard input. -
The first helper to return a username and a non-expired password wins. Remaining helpers are not consulted.
-
If no helper answers, Git prompts you.
-
On success, Git calls
storeon the helpers so the credential is remembered. On an authentication failure it callserase, so a rejected credential is discarded rather than retried forever.
Two consequences follow that explain most confusing behaviour.
Helper order matters, and the first answer wins. If a plaintext store helper is configured
before your keychain helper, the plaintext file is authoritative. Adding a better helper does not
displace a worse one that answers first.
By default the path is ignored. credential.helper matches on protocol and host, so one
credential covers every repository on github.com. That is convenient and it means a token stored for
one repository is offered for all of them. credential.useHttpPath = true changes this, at the cost
of storing more credentials.
printf 'protocol=https\nhost=github.com\n\n' | git credential fillWhat it doesAsks Git's credential system what it would supply for a GitHub HTTPS URL, printing the answer.
Why we run itIt shows the actual credential in effect, rather than the configuration you believe is in effect.
Expected resultKey-value lines including username= and password=. Run it only on a machine you control.
That command prints a live credential to your terminal. It is the fastest way to discover that a forgotten helper is still supplying a token you thought you had removed — and it is not a command to run while screen sharing.
The helpers, and what each one costs
Section titled “The helpers, and what each one costs”Writes credentials to ~/.git-credentials in plaintext, one URL per line, including the token.
git config --global credential.helper storeThe threat is precise: any process running as your user can read that file. A malicious dependency in a project you built, a compromised editor extension, a backup tool syncing your home directory, or another admin on a shared machine all have your Git access.
It also survives indefinitely. There is no timeout and nothing that notices the token has been sitting there since the laptop was provisioned.
Holds credentials in the memory of a daemon process, reachable over a Unix socket restricted to your user by filesystem permissions. Nothing touches disk, and everything is forgotten when the daemon exits or the machine restarts.
{/* Forget after one hour rather than the 15-minute default */}git config --global credential.helper 'cache --timeout=3600'This is a genuine improvement over store for a modest cost. The credential still lives in memory
accessible to processes running as you, but it does not persist, does not get backed up, and expires.
Git Credential Manager
Section titled “Git Credential Manager”The cross-platform helper maintained in the Git ecosystem, running on Windows, macOS and Linux. It brokers authentication and stores the result in the platform’s own credential store — Windows Credential Manager, the macOS Keychain, or a Linux secret service.
Two properties matter for security:
Storage is delegated to the OS. The credential is protected by the platform mechanism, which generally means encryption at rest and access mediated by the OS rather than by file permissions alone.
It can broker OAuth rather than storing a long-lived token. Instead of you creating a personal access token and pasting it in, the helper walks an authentication flow and manages the resulting credential. That removes the class of problem where a token created in 2023 is still in a file somewhere.
macOS osxkeychain, and platform helpers
Section titled “macOS osxkeychain, and platform helpers”macOS ships osxkeychain, which stores credentials in the login keychain. It is a reasonable choice
and it predates GCM.
git config --global credential.helper osxkeychainOn Linux, libsecret integrates with the desktop secret service:
git config --global credential.helper libsecretBoth are better than store. The main advantage GCM adds over them is the authentication brokering,
not the storage.
Comparing the options
Section titled “Comparing the options”| Helper | Where it lives | Survives reboot | Readable by your processes | Reasonable when |
|---|---|---|---|---|
| (none) | Nowhere | — | No | You push rarely and want zero storage |
cache | Daemon memory | No | Via the socket | You want convenience without persistence |
store | ~/.git-credentials, plaintext | Yes | Yes, trivially | Nothing else is available |
osxkeychain | macOS Keychain | Yes | Mediated by the OS | On macOS |
libsecret | Linux secret service | Yes | Mediated by the OS | A secret service is running |
| GCM | Platform store | Yes | Mediated by the OS | You want brokered auth and cross-platform behaviour |
The column that matters is the fourth. Everything in this lesson is about narrowing the set of things that can read the credential without your involvement.
The credential itself
Section titled “The credential itself”Storage is half the problem. The other half is what the credential can do and how long it lasts.
Prefer fine-grained tokens. A classic token with the repo scope can read and write every
repository your account can reach. A fine-grained token names specific repositories and specific
permissions.
Always set an expiry. A token with no expiry is a credential you will forget you issued, on a machine you may no longer own. Expiry converts a silent permanent risk into a scheduled task.
One token per machine and purpose. Rotating a token used in six places means finding six places. Rotating a token used in one place is one action.
Never put a token in the remote URL. This writes it into .git/config in cleartext:
{/* Do not do this */}https://USERNAME:YOUR_TOKEN@github.com/OWNER/REPO.gitIt then appears in git remote -v output, in scripts that echo the remote, in CI logs, and in every
copy of the repository directory. If you find one, rotate the token — assume it has been seen.
Fine-Grained GitHub Tokens covers scoping in depth.
Seeing the protocol directly
Section titled “Seeing the protocol directly”The credential system is small enough to exercise by hand, and doing so is the fastest way to build an accurate model of what is happening. These commands were run against a throwaway host and a placeholder token.
Store a credential:
printf 'protocol=https\nhost=example.test\nusername=demo\npassword=EXAMPLE_TOKEN_VALUE\n\n' \ | git credential approveWith credential.helper store, the file now contains one line:
https://demo:EXAMPLE_TOKEN_VALUE@example.testThat is the whole storage format. The token is the URL’s password component, in cleartext, in a file with no encryption of any kind. Nothing about it is obscured.
Retrieve it:
printf 'protocol=https\nhost=example.test\n\n' | git credential fillprotocol=httpshost=example.testusername=demopassword=EXAMPLE_TOKEN_VALUENote that the request supplied only protocol and host — no username — and a credential came back anyway. That is the default matching behaviour, and it is why one stored credential serves every repository on a host.
Erase it:
printf 'protocol=https\nhost=example.test\n\n' | git credential rejectThe file is emptied. Again, no username was needed: the erase matched on protocol and host, which is
what makes reject a reliable way to clear a stale credential when you do not remember which
username it was stored under.
Three things worth taking from this:
The credential is a set of key-value pairs, not a file format. Every helper implements the same
three operations over the same fields, which is why they compose and why writing one is
straightforward — and why a !command helper can be anything at all.
fill is the ground truth. Whatever it returns is what Git will use. Configuration you believe is
in effect is a hypothesis; this is the measurement.
A shell-snippet helper runs arbitrary code. credential.helper = "!some-command" executes that
command whenever Git needs a credential. Repository-local configuration cannot set it in a cloned
repository — Git protects against that — but it is worth knowing the mechanism exists before pasting
one from an internet answer.
The threat model
Section titled “The threat model”Threat. A token that authenticates to GitHub as you is obtained by another process or another person, and used to read or push to repositories you can reach.
Attack surface. ~/.git-credentials and anything that reads your home directory: backups, sync
clients, container build contexts, diagnostic bundles. The credential cache socket, reachable by any
process running as you. .git/config, if a token was embedded in a remote URL. Terminal scrollback
and screen shares. CI logs, if a token reached a command line. Shell history, if it was ever typed
into a command.
Impact. Determined entirely by the token’s scope. A classic token with repo scope reads and
writes every repository the account can reach, which in an organisation is usually a great deal more
than the machine needed. A fine-grained token limited to two repositories with read access is a much
smaller event.
Control. Storage the OS mediates rather than a file you can cat. Fine-grained scope, so the
worst case is bounded. An expiry date, so an undetected leak stops working on its own. One token per
machine and purpose, so rotation is tractable.
Verification. Run git credential fill and read what comes back. Check the token’s scope and
expiry on GitHub. Search .git/config across your repositories for embedded credentials:
{/* Any remote URL carrying a credential, across every repository under ~/code */}grep -rn "https://[^/]*:[^@]*@" ~/code/*/.git/config 2>/dev/nullEvery one of those is a token to rotate, because you cannot establish where the directory has been.
Credentials in automation
Section titled “Credentials in automation”The rules change when there is no human, and the default answer is different from the interactive one.
In GitHub Actions, use GITHUB_TOKEN. It is minted per run, scoped to the workflow’s repository,
and expires when the job finishes. Most workflows that people configure a personal access token for
do not need one.
For cross-repository access, use a GitHub App. An App installation token is short-lived and scoped to chosen repositories, and it belongs to the App rather than to a person who may leave.
For cloud providers, use OIDC and store nothing at all. See Remove long-lived cloud credentials.
Never use credential.helper store on a shared machine or a build server. A plaintext file on a
long-lived runner is a credential available to every subsequent job, including one from a pull
request.
If a token genuinely must reach a Git operation in CI, pass it through the environment and let Git read it there, rather than writing it into configuration:
git -c "http.extraheader=Authorization: Bearer ${TOKEN}" push origin mainEven this leaves the token in the process list on some systems. The safer general shape is to avoid a token in the first place.
WSL, containers and remote development
Section titled “WSL, containers and remote development”Three environments where the sensible local answer stops applying, and where people fall back to
store because nothing else works.
WSL. A Linux environment on a Windows machine typically has no secret service running, so
libsecret fails. The usual resolution is to point WSL’s Git at the Windows Git Credential Manager
binary, so the credential lives in Windows Credential Manager and is shared between the two
environments. That is a better outcome than a plaintext file inside the WSL filesystem, which is
readable from Windows.
Containers. A development container is a fresh filesystem with no keychain and, often, no interactive terminal for a prompt. Mounting your host credentials into it makes them available to everything in the container, including any dependency you install. Prefer a short-lived token passed in for the session, or perform Git operations on the host.
Remote development over SSH. The credential ends up on the remote machine, protected by whatever
protects that machine. If several people use it, a stored credential is a shared credential. This is
the case where entering a token per operation, or using a short cache timeout, is genuinely the
right answer rather than a purist one.
The pattern across all three: when the environment cannot protect a stored credential, do not store
one. Reaching for store because it is the only helper that works is choosing the worst option
because it is the least effortful.
Rotation
Section titled “Rotation”Rotating an HTTPS credential is straightforward and the step people miss is the erase.
-
Create the replacement token, scoped and with an expiry.
-
Erase the stored one, so Git stops answering with it:
Terminal window printf 'protocol=https\nhost=github.com\n\n' | git credential reject -
Trigger a prompt with any authenticated operation —
git fetchwill do — and supply the new token. -
Confirm with
git credential fillthat the new credential is what comes back. -
Revoke the old token on GitHub. Not before step 4, so you do not lock yourself out mid-change.
-
Check the other places it was used. This is where rotations stall, and the reason for one token per purpose.
Skipping step 2 is the classic mistake: the old credential stays in the helper, keeps being offered, and you conclude the new token is broken.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Usual cause |
|---|---|
Support for password authentication was removed | You entered an account password; GitHub requires a token |
403 on push, 200 on fetch | The token lacks write permission, or the repository is outside its scope |
| Correct token still rejected | An old credential is being supplied by a helper earlier in the chain |
| Prompts on every operation | No helper configured, or the helper cannot reach its store |
| Works locally, fails in CI | The local credential comes from a helper that does not exist on the runner |
| Worked yesterday, fails today | The token expired — which is the system working as intended |
The diagnostic that resolves most of these in one step:
{/* Which helpers are configured, in order */}git config --get-all credential.helper
{/* What they actually return */}printf 'protocol=https\nhost=github.com\n\n' | git credential fillIf the username or token returned is not what you expect, you have found the problem: something earlier in the chain is answering.
HTTPS or SSH?
Section titled “HTTPS or SSH?”Both are fine when configured well. The security-relevant differences:
HTTPS with fine-grained tokens wins on scope and expiry. A token can be limited to two repositories and set to expire in ninety days. An SSH key on an account carries that account’s full access, indefinitely.
SSH wins on at-rest protection when a passphrase is used. A passphrase-protected key is not usable by a process that merely reads the file. Most credential helpers will hand the token to anything running as you.
The combination worth aiming for: SSH with a passphrase for interactive work, fine-grained tokens with expiry for anything scripted, and no stored credential at all in CI.
Common mistakes
Section titled “Common mistakes”Using store without knowing what it means. It was suggested by an error message once and has
been holding a plaintext token ever since.
Embedding a token in the remote URL. Cleartext in .git/config, visible in ordinary output, and
copied with the directory.
Classic tokens with repo scope and no expiry. The broadest, longest-lived credential the
platform offers, for a machine that needed access to one repository.
Not erasing the old credential when rotating. The helper keeps answering with it and the new token looks broken.
Assuming the keychain protects against your own processes. It protects against offline file theft. A process running as you can generally ask for the credential the same way Git does.
Leaving credential.helper configured on a shared runner. A stored credential on a persistent
build machine is available to the next job, whoever’s pull request it came from.
Mental model
Section titled “Mental model”Git does not store credentials. It asks helpers, in order, and uses the first answer. Security is therefore a property of which helper answers first and what that helper does with the secret — not of Git.
What you learned
Section titled “What you learned”- Git delegates credentials to helpers; the first helper to answer wins and the rest are skipped
- By default the credential context is protocol and host, so one token covers a whole host
storewrites plaintext to~/.git-credentials, readable by anything running as youcachekeeps credentials in daemon memory with a timeout and never touches disk- Git Credential Manager delegates storage to the OS and can broker OAuth instead of storing a token
- Tokens in remote URLs end up in
.git/config,git remote -voutput and logs git credential fillshows what is actually in effect;git credential rejecterases it- Rotation is create, erase, re-authenticate, verify, revoke — in that order
- In automation, prefer
GITHUB_TOKEN, then a GitHub App token, then OIDC, over any stored credential
Exercise
Section titled “Exercise”Use a disposable repository and a throwaway fine-grained token scoped to it alone.
-
Run
git config --get-all credential.helper. Predict: how many are configured? -
Run the
git credential fillcommand. Predict: does a credential come back even though you have not pushed in this session? -
Configure
credential.helper storein the disposable repository only, push, then read~/.git-credentials. Confirm the token is in plaintext. -
Add
cacheas an additional helper beforestorein the configuration. Runcredential fillagain. Predict: which helper answers? -
Run
git credential rejectand thengit credential fillagain. Predict: are you prompted? -
Set
credential.useHttpPath trueand repeat. Predict: does the same stored credential still match? -
Revoke the token, remove
~/.git-credentials, and reset the helper configuration.
Related lessons
Section titled “Related lessons”Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.