Scoping deploy keys and tokens Jump to heading

Human access is reviewed when people join and leave. Automation access is granted once, by whoever was setting something up, and reviewed never β€” which is why an inventory typically turns up a writable deploy key for a service decommissioned two years ago and a personal token belonging to someone who left. Each of those is a route to your default branch that no branch protection audit would show. This recipe finds them and narrows them, within repository access control and policy.

When to use this approach Jump to heading

  • Nobody can list the automation that holds write access.
  • A credential in use predates the people currently maintaining the system.
  • An audit asked which non-human identities can change the default branch.
  • You are tightening access after an incident.
  • If your only automation is the forge’s own token and it is scoped per job, you are most of the way there already.

Step 1 β€” Inventory every non-human credential Jump to heading

Four categories, and the last two are where the surprises live.

# Deploy keys, per repository
gh api repos/:owner/:repo/keys --jq '.[] | "\(.title)\tread_only=\(.read_only)\tlast_used=\(.last_used // "never")"'
# Installed apps and their permissions
gh api repos/:owner/:repo/installation --jq '{app: .app_slug, permissions}' 2>/dev/null
# Organisation-level machine accounts
gh api orgs/acme/members --jq '.[].login' | grep -Ei 'bot|svc|ci|deploy|automation'
# Verification: everything on the list has an owner you can name
What an automation inventory usually findsMost credentials are in current use and correctly scoped. A meaningful minority are writable when read access would do, and a smaller set belong to systems that no longer exist β€” each still a valid route into the repository.automation credentials, by statein use, correctly scoped19writable but only reads11belongs to a retired system6owner unknown4the bottom two categories are pure risk with no corresponding benefit

Step 2 β€” Make read-only the default Jump to heading

Most automation reads. Write access is the exception and should look like one.

# Create a deploy key read-only, explicitly
gh api -X POST repos/:owner/:repo/keys \
  -f title='ci-readonly' -f key="$(cat key.pub)" -F read_only=true
# Find the writable ones and justify each
gh api repos/:owner/:repo/keys --jq '.[] | select(.read_only | not) | .title'
# Verification: a read-only key cannot push
GIT_SSH_COMMAND="ssh -i ./ci_readonly" git push origin HEAD:refs/heads/probe 2>&1 | tail -1

A key that needs to push usually needs to push one thing β€” a version bump, a generated artefact, a documentation branch β€” and a narrower mechanism almost always exists. The version-bump case in particular is better served by a release workflow with a scoped token than by a writable key that can change anything.

Step 3 β€” Prefer short-lived credentials Jump to heading

A token that expires in an hour is a token whose leak has a deadline.

# The forge's own token: scoped per job, expires with the run
jobs:
  publish:
    permissions:
      contents: read
      packages: write
# An app installation token, minted per run and short-lived
# (exchange an app's private key for a token valid for one hour)
gh api -X POST "app/installations/$INSTALL_ID/access_tokens" \
  --jq '.expires_at'
# Verification: long-lived personal tokens should not appear in CI at all
gh api repos/:owner/:repo/actions/secrets --jq '.secrets[].name' | grep -Ei 'pat|personal|token'
A long-lived token against a per-run oneA personal access token stored as a secret is valid until someone revokes it, belongs to an individual, and grants whatever scopes it was created with. A per-run token is scoped to one job, expires with the run, and belongs to the repository rather than to a person.Long-lived tokenPer-run tokenvalidity after a leakuntil revokedminutesscopewhatever it was created withper jobtied to a personyesnosurvives offboardingyes β€” a problemnot applicablethe last row is why personal tokens in shared automation are a recurring incident

SAFETY WARNING β€” a personal access token used by shared automation is a credential belonging to one individual with access granted to a system. When that person leaves, either the automation breaks or their access is preserved after offboarding β€” and organisations frequently choose the second without deciding to. Replace personal tokens in shared automation with app or repository-scoped credentials before it becomes an offboarding decision.

Step 4 β€” Remove what is unused Jump to heading

Last-used timestamps make this nearly automatic.

# Deploy keys not used in six months
gh api repos/:owner/:repo/keys --jq '.[] | select(.last_used == null or (.last_used < "2026-03-18")) | "\(.id)\t\(.title)\t\(.last_used // "never")"'
# Remove, having checked with the owner
gh api -X DELETE "repos/:owner/:repo/keys/$KEY_ID"
# Verification: nothing broke β€” watch the pipelines for a week
gh run list --limit 20 --json conclusion --jq 'group_by(.conclusion)[] | {r: .[0].conclusion, n: length}'

Removing an unused credential is reversible: if something breaks, it is re-created in a minute. That asymmetry is worth stating when someone hesitates, because the alternative β€” leaving it because removing it might break something β€” is how the inventory grew in the first place.

Step 5 β€” Rotate what must remain Jump to heading

# Generate a replacement, add it, switch the consumer, remove the old one
ssh-keygen -t ed25519 -C 'ci-deploy 2026-09' -f ./ci_deploy_new -N ''
gh api -X POST repos/:owner/:repo/keys -f title='ci-deploy-2026-09' -f key="$(cat ci_deploy_new.pub)" -F read_only=true
# After the consumer is switched
gh api -X DELETE "repos/:owner/:repo/keys/$OLD_KEY_ID"
# Verification: the old key no longer authenticates
GIT_SSH_COMMAND="ssh -i ./ci_deploy_old" git ls-remote origin 2>&1 | tail -1
The credential lifecycle that keeps the inventory smallEvery credential is created read-only unless a written reason says otherwise, recorded with an owner, checked for use on a schedule, and removed when unused. Rotation replaces rather than accumulates.Createread-only by defaultRecordowner and purposeReviewlast used, quarterlyRemove or rotatereversible in a minutewithout the third box the inventory only ever grows

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Deploy key or machine account? Jump to heading

A deploy key is scoped to one repository, which is its main advantage and its main limitation. A machine account can span repositories and is correspondingly more dangerous β€” it holds organisation-level access and appears as a member. Prefer per-repository keys, and where an account is unavoidable, treat it as a privileged identity with the same review as a human one.

What about tokens in developer machines and local scripts? Jump to heading

They are outside repository settings and worth a separate pass. The inventory in Step 1 covers what the forge knows about; a token in someone’s shell profile is visible only to them. Encouraging short-lived credentials and forge CLI authentication rather than stored tokens addresses most of it.

How do we handle a credential nobody claims? Jump to heading

Remove it, after announcing that you will and giving a week. An unclaimed credential with write access is a route nobody is accountable for, and the cost of being wrong is one broken pipeline and a recreation β€” far smaller than the cost of leaving it.