Skip to content

Git Credentials: Storage, Helpers and Rotation

Lesson 7 of 9Intermediate14 min readGit Security & DevSecOps · Repository SecurityVerified: git 2.43.0 on Ubuntu 24.04; git-credentials(7) and Git Credential Manager documentation, September 2026

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.

Find out what you are currently using:

Terminal window
git config --get-all credential.helper

Then aim for one of these, in descending order of preference:

  1. An OS keychain via Git Credential Manager — Windows Credential Manager, macOS Keychain, or a Linux secret service.
  2. cache with a timeout — in memory only, forgotten on reboot.
  3. Nothing at all, entering the token per operation. Tedious, and genuinely safe.
  4. 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.

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.

  1. Git needs a credential for a URL. It builds a context: protocol, host, and — only if credential.useHttpPath is set — the path.

  2. It runs each configured helper in turn with the get operation, passing the context on standard input.

  3. The first helper to return a username and a non-expired password wins. Remaining helpers are not consulted.

  4. If no helper answers, Git prompts you.

  5. On success, Git calls store on the helpers so the credential is remembered. On an authentication failure it calls erase, 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.

Terminal window
printf 'protocol=https\nhost=github.com\n\n' | git credential fill

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

Writes credentials to ~/.git-credentials in plaintext, one URL per line, including the token.

Terminal window
git config --global credential.helper store

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

Terminal window
{/* 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.

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 ships osxkeychain, which stores credentials in the login keychain. It is a reasonable choice and it predates GCM.

Terminal window
git config --global credential.helper osxkeychain

On Linux, libsecret integrates with the desktop secret service:

Terminal window
git config --global credential.helper libsecret

Both are better than store. The main advantage GCM adds over them is the authentication brokering, not the storage.

HelperWhere it livesSurvives rebootReadable by your processesReasonable when
(none)NowhereNoYou push rarely and want zero storage
cacheDaemon memoryNoVia the socketYou want convenience without persistence
store~/.git-credentials, plaintextYesYes, triviallyNothing else is available
osxkeychainmacOS KeychainYesMediated by the OSOn macOS
libsecretLinux secret serviceYesMediated by the OSA secret service is running
GCMPlatform storeYesMediated by the OSYou 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.

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

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

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:

Terminal window
printf 'protocol=https\nhost=example.test\nusername=demo\npassword=EXAMPLE_TOKEN_VALUE\n\n' \
| git credential approve

With credential.helper store, the file now contains one line:

https://demo:EXAMPLE_TOKEN_VALUE@example.test

That 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:

Terminal window
printf 'protocol=https\nhost=example.test\n\n' | git credential fill
protocol=https
host=example.test
username=demo
password=EXAMPLE_TOKEN_VALUE

Note 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:

Terminal window
printf 'protocol=https\nhost=example.test\n\n' | git credential reject

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

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:

Terminal window
{/* Any remote URL carrying a credential, across every repository under ~/code */}
grep -rn "https://[^/]*:[^@]*@" ~/code/*/.git/config 2>/dev/null

Every one of those is a token to rotate, because you cannot establish where the directory has been.

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:

Terminal window
git -c "http.extraheader=Authorization: Bearer ${TOKEN}" push origin main

Even this leaves the token in the process list on some systems. The safer general shape is to avoid a token in the first place.

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.

Rotating an HTTPS credential is straightforward and the step people miss is the erase.

  1. Create the replacement token, scoped and with an expiry.

  2. Erase the stored one, so Git stops answering with it:

    Terminal window
    printf 'protocol=https\nhost=github.com\n\n' | git credential reject
  3. Trigger a prompt with any authenticated operation — git fetch will do — and supply the new token.

  4. Confirm with git credential fill that the new credential is what comes back.

  5. Revoke the old token on GitHub. Not before step 4, so you do not lock yourself out mid-change.

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

SymptomUsual cause
Support for password authentication was removedYou entered an account password; GitHub requires a token
403 on push, 200 on fetchThe token lacks write permission, or the repository is outside its scope
Correct token still rejectedAn old credential is being supplied by a helper earlier in the chain
Prompts on every operationNo helper configured, or the helper cannot reach its store
Works locally, fails in CIThe local credential comes from a helper that does not exist on the runner
Worked yesterday, fails todayThe token expired — which is the system working as intended

The diagnostic that resolves most of these in one step:

Terminal window
{/* 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 fill

If the username or token returned is not what you expect, you have found the problem: something earlier in the chain is answering.

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.

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.

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.

  • 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
  • store writes plaintext to ~/.git-credentials, readable by anything running as you
  • cache keeps 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 -v output and logs
  • git credential fill shows what is actually in effect; git credential reject erases 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

Use a disposable repository and a throwaway fine-grained token scoped to it alone.

  1. Run git config --get-all credential.helper. Predict: how many are configured?

  2. Run the git credential fill command. Predict: does a credential come back even though you have not pushed in this session?

  3. Configure credential.helper store in the disposable repository only, push, then read ~/.git-credentials. Confirm the token is in plaintext.

  4. Add cache as an additional helper before store in the configuration. Run credential fill again. Predict: which helper answers?

  5. Run git credential reject and then git credential fill again. Predict: are you prompted?

  6. Set credential.useHttpPath true and repeat. Predict: does the same stored credential still match?

  7. Revoke the token, remove ~/.git-credentials, and reset the helper configuration.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.