Git: Authentication & Access Control
Git has no user accounts and enforces no permissions of its own: anyone with a copy of a repository can read and rewrite it. Authentication (who is connecting) and authorization (what they may do) are handled by the transport, SSH or HTTPS, and by the hosting server or forge. A separate layer, commit and tag signing, records who created an object in a way anyone can verify later, independently of how it was transported. This page covers both layers, Git’s own local trust settings, and how to contain a leaked credential.
Three separate questions
| Question | Mechanism | Checked by | When |
|---|---|---|---|
| May this connection fetch or push? | SSH key, token, OAuth, SSO | The server | At each fetch, clone and push |
| What may this identity do to this repository and ref? | Roles, branch protection, rulesets, server hooks | The server | At each push (and in the web UI) |
| Who created this commit or tag, and is it unaltered? | GPG, SSH or X.509 signature | Anyone | At any time after creation |
The answers are independent. A commit can be pushed by an authenticated user but be unsigned, or be validly signed but pushed with a stolen token. Author and committer names in a commit are plain text that anyone can set (git -c user.email=ceo@example.com commit), so only a signature says anything about authorship.
flowchart LR
Dev["Developer"] -- "SSH key or token" --> Auth["Transport authentication"]
Auth --> SSO["Organisation policy<br/>SSO, 2FA, IP allow list"]
SSO --> Authz["Authorization<br/>roles, rulesets, CODEOWNERS"]
Authz --> Hooks["Push checks<br/>pre-receive hooks, secret scanning,<br/>required signatures"]
Hooks --> Repo[("Repository")]
Repo -. "anyone can verify later" .-> Verify["Signature verification"]
Choosing a credential
| Consumer | Recommended credential | Why |
|---|---|---|
| Developer workstation | SSH key (hardware-backed if possible) or HTTPS through Git Credential Manager with OAuth | No long-lived secret to paste; keys can require a touch per use |
| CI job on the forge’s own runners | The job’s built-in, per-run token (for example GitHub Actions’ GITHUB_TOKEN) |
Minted per job, scoped to the repository, expires automatically |
| Automation across several repositories | App installation token (GitHub App, GitLab group or project access token) | Short-lived, scoped, not tied to a person, individually revocable |
| A single server that pulls one repository | Read-only deploy key | Blast radius of one repository |
| Ad-hoc scripts using the API | Fine-grained personal access token with an expiry | Scoped to specific repositories and permissions |
| Anything else | Avoid classic tokens and shared human accounts | Account-wide scope and poor attribution |
Transports
The remote URL’s scheme selects the transport and therefore the authentication method:
| URL form | Transport | Authenticates with |
|---|---|---|
git@github.com:org/repo.git (scp-like) |
SSH | Key pair |
ssh://git@host:2222/org/repo.git |
SSH | Key pair |
https://github.com/org/repo.git |
HTTPS | Username plus token or OAuth token, via a credential helper |
git://host/repo.git |
Git daemon | Nothing: anonymous, unencrypted, read-only |
/srv/repo.git, file:// |
Local | Filesystem permissions |
The unauthenticated git:// protocol offers no integrity protection against network attackers, and GitHub disabled it in 2022. In practice the choice is between SSH and HTTPS.
git remote -v
git remote set-url origin git@github.com:org/repo.git # switch to SSH
git remote set-url origin https://github.com/org/repo.git # switch to HTTPS
SSH or HTTPS? SSH suits developer machines (a key plus an agent, nothing to paste) and fixed hosts with deploy keys. HTTPS passes more easily through corporate proxies on port 443 and fits OAuth, SSO and short-lived tokens. Both are secure when configured as below. url.<base>.insteadOf rewrites URLs transparently, so you can use SSH even when a project’s submodules or scripts use HTTPS URLs:
git config --global url."git@github.com:".insteadOf "https://github.com/"
SSH keys
SSH uses public-key authentication: the server holds your public key and challenges the client to prove possession of the matching private key, which never leaves your machine.
Generating a key
# Default choice
ssh-keygen -t ed25519 -C "you@example.com"
# FIDO2 hardware key (OpenSSH 8.2+): the private key never leaves the device
ssh-keygen -t ed25519-sk -O resident -O verify-required -C "you@example.com"
# Only for servers that cannot use Ed25519
ssh-keygen -t rsa -b 4096 -C "you@example.com"
With -sk key types the file on disk is only a handle; signing requires the physical authenticator and a touch (plus a PIN with verify-required). A stolen laptop or a copied ~/.ssh directory is then not enough to push. -O resident stores the handle on the device so ssh-keygen -K can recreate it on another machine.
Protect software keys with a passphrase and let ssh-agent (or the OS keychain) cache the decrypted key:
eval "$(ssh-agent -s)"
ssh-add -t 8h ~/.ssh/id_ed25519 # forget it after 8 hours
ssh-add -l # list loaded keys
Registering and testing
Upload the public half (~/.ssh/id_ed25519.pub) to your account, then test:
ssh -T git@github.com # greets you by username
ssh -vT git@github.com # shows which keys were offered and accepted
Several identities on one host
A host such as github.com identifies you by the key you present, so work and personal accounts need separate keys and a way to select them. Define host aliases in ~/.ssh/config:
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
Host github.com
IdentityFile ~/.ssh/id_ed25519_personal
IdentitiesOnly yes
Clone work repositories as git@github-work:org/repo.git. IdentitiesOnly yes stops the client from offering every key the agent holds, which otherwise can pick the wrong account or hit the server’s limit on authentication attempts. An alternative that avoids aliases is Git’s core.sshCommand, set per repository or through a conditional include:
# ~/.gitconfig
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
# ~/.gitconfig-work
[core]
sshCommand = ssh -i ~/.ssh/id_ed25519_work -o IdentitiesOnly=yes
[user]
email = you@company.example
Verifying the server
Authentication is mutual: the server presents a host key, and trusting the wrong one allows a man-in-the-middle. On first connection compare the fingerprint with the one the host publishes (GitHub lists its keys in its documentation and at https://api.github.com/meta) rather than accepting blindly:
ssh-keygen -lF github.com # fingerprint(s) already in known_hosts
A “REMOTE HOST IDENTIFICATION HAS CHANGED” warning for an established host deserves investigation. It can be legitimate (GitHub replaced its RSA host key in March 2023 after the private key was briefly exposed), but confirm against the host’s announcement before editing known_hosts.
Some forges (GitHub Enterprise, GitLab self-managed) also accept SSH certificates signed by an organisation’s certificate authority, which lets an organisation issue short-lived SSH credentials centrally instead of collecting individual public keys.
Deploy keys
A deploy key is an SSH public key attached to one repository rather than to a user.
| Property | User SSH key | Deploy key |
|---|---|---|
| Scope | Every repository the user can access | One repository |
| Access | Follows the user’s role | Read-only unless write is explicitly granted |
| Tied to | A person | A repository |
| Reuse | One key, many repositories | A key can be registered on only one repository |
| Leak impact | Everything the user can reach | That repository |
ssh-keygen -t ed25519 -C "deploy: example-service" -f ~/.ssh/deploy_example -N ""
# Register deploy_example.pub under the repository's Deploy keys;
# enable write access only if the job must push (tags, a gh-pages branch).
Unattended keys have no passphrase, so compensate with narrow scope and rotation. When one server needs several repositories, a machine identity (a GitHub App, or GitLab group or project access tokens) scales better than many deploy keys.
HTTPS, tokens and credential helpers
Tokens instead of passwords
Major forges no longer accept account passwords for Git over HTTPS; you authenticate with a token that can be scoped, given an expiry, and revoked individually.
On GitHub:
| Token | Scope | Notes |
|---|---|---|
| Fine-grained personal access token | One user or organisation; selected repositories; per-permission read or write | Recommended by GitHub where possible. Cannot span multiple organisations or act as an outside collaborator; at most 50 per user. Organisations can require approval. |
| Classic personal access token | Coarse scopes such as repo, which covers every repository you can access |
Needed only for the cases fine-grained tokens do not support. Unused classic tokens are removed automatically after a year. |
| GitHub App installation token | The repositories and permissions the App was granted | Expires after one hour; the App’s identity, not a person’s, appears in audit logs |
GITHUB_TOKEN in Actions |
The workflow’s repository, with permissions set by the workflow’s permissions: block |
Created per job and revoked when the job ends |
GitLab offers the equivalent personal, project and group access tokens plus CI job tokens; Bitbucket uses API tokens and repository or workspace access tokens.
Good practice for any token:
- Grant the minimum permissions and repositories, and set the shortest workable expiry.
- Use one token per consumer so a revocation affects one thing and audit logs show which consumer acted.
- Never put a token in a remote URL (
https://user:TOKEN@host/...). It is written to.git/config, printed bygit remote -v, and easily ends up in shell history and CI logs. Use a credential helper.
Credential helpers
A credential helper supplies HTTPS credentials to Git so you are not prompted on every operation.
| Helper | Storage | Notes |
|---|---|---|
manager (Git Credential Manager) |
OS keystore | Cross-platform; performs OAuth in the browser, including device-code flow, and refreshes tokens; handles GitHub, GitLab, Bitbucket and Azure Repos |
osxkeychain |
macOS Keychain | Ships with Git on macOS |
wincred |
Windows Credential Manager | Superseded by GCM, which Git for Windows installs |
libsecret |
Secret Service (GNOME Keyring, KWallet) | Linux; often packaged separately |
cache |
Memory of a background daemon | Nothing on disk; expires after --timeout seconds |
store |
Plaintext file ~/.git-credentials |
Avoid: the secret is stored unencrypted |
git config --global credential.helper manager
git config --global credential.helper 'cache --timeout=3600'
# Different settings per host
git config --global credential.https://git.example.com.username alice
git config --global credential.https://dev.azure.com.useHttpPath true # one credential per repository path
The helper protocol is plain key/value text on standard input and output. Git asks a helper to get a credential before a request, then tells it to store it after success or erase it after rejection. Any program implementing those verbs can act as a helper, including one that mints a short-lived token on demand. Recent Git versions also pass the credential’s expiry (password_expiry_utc) and an OAuth refresh token (oauth_refresh_token) through this protocol, so helpers can discard expired tokens and refresh them. You can see what Git would send with:
printf 'protocol=https\nhost=github.com\n\n' | git credential fill
With GCM or the forge’s CLI acting as helper (gh auth setup-git configures the GitHub CLI this way), you never handle a long-lived token for interactive work.
Commit and tag signing
What a signature covers
A commit object is a short text record: tree hash, parent hash(es), author, committer and message. Signing produces a signature over exactly those bytes and stores it in the commit’s gpgsig header (gpgsig-sha256 in SHA-256 repositories). Because the commit includes its tree and parent hashes, and each of those commits to its own contents (the Merkle property described in Object Model & Storage), a valid signature fixes the entire tree and history reachable from that commit. Changing any ancestor changes every descendant hash and invalidates the signature. Annotated tags are signed the same way, which is how release tags are usually vouched for.
A signature proves that the key holder signed these bytes. It does not prove the key holder wrote the code, and any rewrite (rebase, amend, filter-repo, a forge’s “rebase and merge” button) produces new commits that are unsigned or signed by whoever rewrote them.
sequenceDiagram
participant D as Developer
participant G as git
participant K as Signer (gpg, ssh-keygen, gitsign)
participant V as Verifier (forge, CI, git log)
D->>G: git commit -S
G->>K: commit bytes without signature
K-->>G: detached signature
G->>G: store signature in gpgsig header, hash the object
V->>G: git verify-commit
G->>K: bytes + signature + trusted keys
K-->>V: good / bad / unknown key
Signing formats
Format (gpg.format) |
Keys | Trust model | Notes |
|---|---|---|---|
openpgp (default) |
GnuPG keys | Web of trust or keys registered on the forge | Mature; key management is the main burden |
ssh (Git 2.34+) |
Any SSH key, including -sk hardware keys |
An allowed_signers file, or keys registered on the forge |
Simplest to adopt; the forge must know the key as a signing key |
x509 |
X.509 certificates (S/MIME via gpgsm, or Sigstore’s gitsign) |
Certificate authorities | gitsign issues short-lived certificates bound to an OIDC identity and logs signatures in the public Rekor transparency log |
SSH signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_signing.pub
git config --global commit.gpgSign true
git config --global tag.gpgSign true
Local verification needs an allowed-signers file mapping identities to keys:
# ~/.config/git/allowed_signers
you@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
For a team, a tracked allowed_signers file in the repository gives every clone the same verification policy. Forges verify against the signing keys registered on each user’s account.
GPG signing
gpg --quick-generate-key "Your Name <you@example.com>" ed25519 sign 2y
gpg --list-secret-keys --keyid-format=long # find the key ID
git config --global user.signingkey 3AA5C34371567BD2
git config --global commit.gpgSign true
gpg --armor --export 3AA5C34371567BD2 # public key to upload to the forge
The email in the key’s user ID must match the commit’s committer email and a verified email on the forge account for the commit to be shown as verified. GnuPG 2.1 and later write a revocation certificate to ~/.gnupg/openpgp-revocs.d/ when a key is created; keep a copy offline.
Sigstore gitsign
gitsign removes long-lived signing keys entirely: at signing time it opens an OIDC login (or uses a CI workload identity), obtains a certificate valid for minutes from the Fulcio CA, signs, and records the signature in the Rekor transparency log.
git config --local gpg.format x509
git config --local gpg.x509.program gitsign
git config --local commit.gpgSign true
Verification checks the identity and issuer embedded in the certificate (gitsign verify --certificate-identity=... --certificate-oidc-issuer=...) rather than a key. Forge support for displaying such commits as verified varies.
Separate authentication and signing keys
An authentication key is used against many servers and may be forwarded through agents; a signing key makes durable statements that others rely on long after the fact. Keeping them distinct means one can be rotated without the other, and a forwarded authentication key cannot be used to sign. Forges register the two roles separately even when the key bytes are the same. Hardware-backed keys are well suited to signing.
Verifying signatures
git log --show-signature -3
git verify-commit HEAD
git verify-tag v1.2.0
git log --format='%h %G? %GS %s'
%G? |
Meaning |
|---|---|
G |
Good signature from a trusted key |
U |
Good signature, key of unknown or undefined trust |
X |
Good signature that has expired |
Y |
Good signature made by a key that has since expired |
R |
Good signature made by a key that has since been revoked |
B |
Bad signature: the content does not match |
E |
Cannot be checked, for example the key is missing |
N |
No signature |
Enforcing signatures
Local configuration is advisory. Enforcement belongs on the server: forge rulesets and branch protection can require signed commits, and GitHub’s vigilant mode marks a user’s unsigned commits as unverified. A CI check adds defence in depth:
# Fail if any commit on this branch is unsigned or has a bad signature
set -eu
for c in $(git rev-list origin/main..HEAD); do
case "$(git log -1 --format=%G? "$c")" in
G) ;; # accept only fully trusted signatures
*) echo "Unverified commit: $c" >&2; exit 1 ;;
esac
done
For this to pass with SSH signing, the CI job must configure gpg.ssh.allowedSignersFile with the team’s keys; otherwise every signature is reported as unverifiable.
Authorization on the server
Organisation identity: SSO
Enterprises connect the forge to an identity provider (Okta, Microsoft Entra ID, Google Workspace) with SAML or OpenID Connect, making it the single source of truth for who exists, which groups they belong to, and when access ends. Offboarding a person in the identity provider then removes their forge access.
SSO governs web sessions, but Git operations use keys and tokens. Under GitHub’s SAML enforcement, an existing personal access token or SSH key must be explicitly authorized for the organisation before it works against that organisation’s repositories; until then a push or fetch fails with a message saying the organisation has enabled or enforced SAML SSO. The fix is to open the token or key in account settings and authorize it for the organisation. With Enterprise Managed Users, accounts are created and controlled by the identity provider itself.
OAuth and App grants
Third-party tools (CI services, review bots, editor integrations) receive access through an OAuth consent screen or by being installed as an App. Grant the narrowest scopes offered, review grants periodically in account and organisation settings, and treat an integration asking for organisation-admin or all-repository write access without an obvious need as a risk.
Repository and ref rules
Independently of how a user signed in, the forge enforces:
- Roles and teams, for example read, triage, write, maintain and admin on GitHub, or guest through owner on GitLab.
- Branch protection and rulesets: required pull requests and reviews, required status checks, required signed commits, linear history, and blocking force-pushes and deletions on protected branches and tags.
- CODEOWNERS: required review from the owners of the paths a change touches.
- Push checks: secret-scanning push protection, file-size and path restrictions, and on self-hosted servers
pre-receiveandupdatehooks (see Hooks).
Git’s own local trust boundaries
Although Git has no access control, it does decide which repositories it trusts on the local machine, because a repository’s configuration and hooks can run arbitrary commands.
| Setting | Default | Purpose |
|---|---|---|
safe.directory |
Only repositories owned by the current user | Refuses to operate on a repository owned by another user (for example in a shared /tmp or a mounted volume), since its config could run code as you. Add trusted paths explicitly; container builds often need git config --global --add safe.directory /workspace. |
safe.bareRepository |
all (planned to become explicit in Git 3.0) |
With explicit, Git ignores bare repositories it discovers inside a working tree, closing an attack where a cloned project embeds a bare repository with malicious hooks. |
protocol.allow, protocol.<name>.allow |
file is limited to user-initiated operations |
Restricts which transports submodules and other indirect operations may use. |
transfer.fsckObjects |
false |
Validate every object received on fetch and push. |
Hooks in .git/hooks are never cloned, but project-provided tooling such as pre-commit configurations runs code from the repository once installed, so install it only for repositories you trust.
Revocation, rotation and audit
Assume every credential will eventually leak and plan for fast revocation, routine rotation and useful audit trails.
| Credential | Revoke by | Effect |
|---|---|---|
| User SSH key | Deleting it from the account | Every connection using it fails immediately |
| Deploy key | Removing it from the repository | That repository only |
| Personal access token | Revoking it | That token only |
| OAuth grant | Revoking the application’s authorization | That application’s tokens |
| App | Rotating its private key or uninstalling it | All tokens it could mint |
| GPG signing key | Publishing the revocation certificate | Signatures verify as R (revoked key) |
| SSH signing key | Removing it from the account and allowed_signers, or marking it revoked (gpg.ssh.revocationFile) |
Signatures no longer verify |
Rotation. Prefer credentials that expire on their own (fine-grained tokens, App and job tokens). For long-lived keys, rotate without downtime by adding the new key, switching the consumer, then removing the old one.
Audit. Forge audit logs record key and token creation, SSO events, permission changes and pushes, including force-pushes; stream them to a SIEM. Signatures provide a separate, cryptographic record of authorship. Reflogs of a local or mirror clone help reconstruct what a force-push replaced.
Leaked secrets
The most common Git security incident is not a broken credential but a secret (an API key, a .env file, a private key) committed to a repository and pushed, after which it lives in history and possibly on a public server.
Prevention
.env
.env.*
!.env.example
*.pem
*.key
*.p12
id_rsa
id_ed25519
credentials.json
**/secrets/
- Keep secrets in environment variables, a secrets manager (Vault, a cloud secrets service) or the CI system’s secret store; see Cybersecurity.
- Scan staged changes before each commit. With the pre-commit framework, add the
gitleakshook; as a plain hook (gitleaks 8.19 and later replacedprotect --stagedwithgit --pre-commit --staged):
#!/bin/sh
# .git/hooks/pre-commit
exec gitleaks git --pre-commit --staged --redact --verbose
- Turn on server-side secret scanning and push protection, which rejects pushes containing recognisable credentials. GitHub enables push protection by default for public repositories; other forges offer equivalents.
Response
A secret must be considered compromised from the moment it was pushed; automated scanners harvest public commits within minutes.
flowchart LR
A["1. Revoke and rotate<br/>the secret at its source"] --> B["2. Check audit logs<br/>for use during exposure"]
B --> C["3. Purge from history<br/>git filter-repo"]
C --> D["4. Force-push, have<br/>collaborators re-clone"]
D --> E["5. Ask the forge to purge<br/>cached views if needed"]
- Revoke and rotate first. Removing a secret from history does nothing about copies already taken. Invalidate it at the issuing service.
- Audit the issuing service’s logs for use during the exposure window.
- Purge it from history with
git filter-repo, the tool the Git project recommends in place ofgit filter-branch:
git filter-repo --invert-paths --path config/secrets.yml # remove a file from all history
git filter-repo --replace-text expressions.txt # redact matching strings
git remote add origin <url> # filter-repo removes the remote as a safety measure
git push origin --force --all && git push origin --force --tags
- Coordinate. Every commit hash after the first affected commit changes. Collaborators must re-clone or hard-reset, and forks, open pull requests and existing clones retain the old objects.
- Clean up the host. Forges may keep unreferenced commits reachable by SHA and in pull-request views; GitHub’s documentation on removing sensitive data describes contacting support to purge them.
Revocation is immediate and complete; history rewriting is slow, disruptive and never reaches every copy. Treat the purge as cleanup, not as the fix.
See also
- Object Model & Storage: why one signature covers all reachable history
- Algorithms & Advanced Operations: client and server hooks
- Protocols, Packs & Performance: the SSH and HTTPS wire protocol these credentials authenticate
- Git Command Reference: signing and removing sensitive data command syntax
- Branching Strategies: branch protection and review in team workflows
- Cybersecurity: secrets management and defence in depth
- CI/CD: short-lived credentials in pipelines
Previous: Conflict Resolution & Recovery · Back to Git Internals