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 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' 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 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.
Related Jump to heading
- Repository Access Control & Policy β the parent topic and the routes an audit must cover.
- Limiting Workflow Permissions Per Job β the same discipline inside the pipeline.
- Offboarding a Developer From Every Repository β where personal tokens in automation become a problem.