Nobody grants excessive access deliberately. It accumulates: an admin role handed over to unblock a release, a token created for one script that ends up used by four, a deploy key added during a migration that finished two years ago.
Nothing in GitHub prompts a review. The only thing that surfaces accumulated access is somebody deciding to look.
The short answer
Section titled “The short answer”Two principles carry the whole lesson.
Least privilege. An identity holds the minimum access needed for its purpose, for the minimum time. The measure is not what someone might plausibly need next month — it is what they need to do the thing they are doing.
Human and machine identities are different problems. People forget, get phished, change roles and leave. Machines do none of those, and also never notice that their access is excessive and never hand it back. Managing both with one mechanism produces a system that fits neither.
Practically, that means:
- Access through teams, never individual grants
- Fine-grained tokens with expiry, never classic tokens with
repo - GitHub Apps for anything ongoing, never a person’s token doing automation
GITHUB_TOKENread-only by default, widened per job- Environments with required reviewers for anything that reaches production
- A review on a schedule, because everything above drifts
The two questions
Section titled “The two questions”Every access decision reduces to two questions asked in order.
Authentication: who or what is acting? A person with a password and a second factor. A key. A token. An App installation. A workflow run.
Authorization: what may that identity do? A repository role. A team’s permission. A token’s
scope. A workflow’s permissions: block.
Conflating them is the source of most confused access models. “We trust Alice” is an authentication statement being used to answer an authorization question — and the relevant risk is not whether Alice is trustworthy but what happens when her session belongs to somebody else.
Repository roles
Section titled “Repository roles”| Role | Adds |
|---|---|
| Read | Clone, fetch, open issues and pull requests |
| Triage | Manage issues and pull requests without write access |
| Write | Push branches, create tags, edit workflow files |
| Maintain | Some repository settings, without destructive operations |
| Admin | All settings, rules, access, and deletion |
Two of these are handed out too readily.
Write includes editing .github/workflows/. In a repository with CI, that is the ability to
change what executes with the repository’s credentials — usually the most powerful thing write access
confers, and entirely invisible in the role’s name. A CODEOWNERS entry for /.github/ is the
cheapest available mitigation.
Admin includes changing the rules that constrain everybody else. An admin does not need to break a protection rule; they can amend it, act, and restore it. This is why the number of admins is a better security metric than the number of protection rules.
Triage is underused. Most people who “need write access to manage issues” need Triage.
Teams, not people
Section titled “Teams, not people”Granting access to individuals produces an access model nobody can read. “Who can write to this repository?” requires opening every repository, and offboarding becomes a search.
Teams fix this by making access a property of membership:
- Nested teams inherit. A
platformteam’s access flows toplatform/networking, which lets structure follow the organisation. - Membership is the control. Removing someone from a team removes their access everywhere that team is used, in one action.
- The grant is legible. “Which teams have write access here?” is a short answer.
The rule worth adopting: a person is never granted repository access directly. If somebody needs access that no team confers, that is a signal the team structure is wrong, or that the need is temporary and should be handled as such.
Outside collaborators are the exception that proves it — a contractor or an open-source contributor who should not be an organisation member. They get repository-scoped access, and because they are outside the team structure, they are exactly the population an access review must enumerate separately.
The machine identities
Section titled “The machine identities”This is where accumulated access hides, because none of these are on anybody’s offboarding list.
| Identity | Scope | Expires | Belongs to |
|---|---|---|---|
GITHUB_TOKEN | The workflow’s own repository | End of job | The workflow run |
| GitHub App installation token | Chosen repositories, chosen permissions | ~1 hour | The App |
| Fine-grained PAT | Chosen repositories, chosen permissions | A date you set | A person |
| Classic PAT | Everything the account can reach, by scope | Optional | A person |
| Deploy key | One repository | Never | Nobody |
| SSH key on an account | Everything the account can reach | Never | A person |
Read the “Expires” and “Belongs to” columns together. The bottom three rows are credentials that never expire, and two of the three belong to a person whose departure will not remove them.
A decision framework
Section titled “A decision framework”| The job needs to… | Use |
|---|---|
| Act on its own repository from a workflow | GITHUB_TOKEN with scoped permissions: |
| Act on other repositories from a workflow | A GitHub App token minted per run |
| Run as an integration or a bot, ongoing | A GitHub App |
| Clone one repository from a server | A read-only deploy key |
| Authenticate to a cloud provider | OIDC — no stored credential at all |
| Let a person script something occasionally | A fine-grained PAT with an expiry |
| Anything else | Reconsider — the list above covers nearly everything |
The top row is the one people skip. A great many workflows have a personal access token in a secret,
doing something GITHUB_TOKEN would have done.
Actions permissions
Section titled “Actions permissions”Workflow permissions are where repository access meets automation, and the default matters.
Set the repository default for GITHUB_TOKEN to read-only. Workflows then widen it explicitly:
permissions: contents: read
jobs: release: permissions: contents: write packages: write id-token: writeThree benefits, all of them about review rather than about the runtime:
The grant is visible in the diff. A pull request that adds contents: write is a pull request
that changes what the job can do, and it shows up as such.
Third-party actions inherit the job’s token. A job with a read-only token limits what a compromised action can do with it.
It documents intent. Reading a workflow tells you what it is allowed to touch.
Least-privilege permissions covers the full permission list; the point here is that this is part of the same access model as repository roles, not a separate concern.
Deployment access
Section titled “Deployment access”Production access should not be a repository role. It should be an environment with protection rules.
An environment holds its own secrets, and can require:
- Named reviewers who must approve before a job may run
- A wait timer
- Branch and tag restrictions, so only
mainor av*tag can deploy
The security property that matters: secrets attached to an environment are not readable by a job until the environment’s protection rules are satisfied. A pull request from a fork cannot reach them, and neither can a workflow somebody adds on a branch.
That converts “who can deploy to production” from a repository role — which also grants everything else — into a specific, reviewable, separately-held permission. See Environments and Deployment approvals.
Temporary access
Section titled “Temporary access”Most excessive access started as necessary temporary access that nobody removed.
The problem is that “temporary” has no mechanism behind it. Making it real requires one of:
An expiry the platform enforces. Fine-grained tokens have a maximum lifetime. Use it.
A break-glass identity. An account or team that is not anybody’s daily identity, whose use is conspicuous, with an agreed procedure for using it and a required write-up afterwards. The value is not that it prevents anything — it is that using it is visible, which is what a bypass on a normal account is not.
A scheduled removal. If access must be granted to a person for a week, the calendar entry to remove it is part of granting it.
The failure mode to avoid is the “temporary admin” that is still there at the next access review, at which point nobody remembers why and nobody wants to be the one who breaks something by removing it.
The threat model
Section titled “The threat model”Threat. An identity is used to do something its owner never intended — because it was compromised, because it outlived its purpose, or because it always held more access than the task required.
Attack surface. Every account in the organisation and every credential those accounts hold. Every machine identity: deploy keys, App installations, tokens in secrets, tokens on laptops. Outside collaborators. Anything with organisation-wide scope, which is a much shorter list than the total and a much more valuable one.
Impact. Bounded by scope, which is the entire reason scope is the control. A compromised
fine-grained token limited to one repository with read access is an inconvenience. A compromised
classic token with repo scope belonging to an organisation owner is a full compromise of everything
that organisation builds and deploys.
Control. Fewer identities, narrower scope, shorter lifetimes, and grants that are legible enough to review.
Verification. Take the most privileged credential you can find and ask what it reaches. Then ask who would notice if it were used at 03:00 from an unfamiliar location, and how.
That last question is the one that separates an access model from an access diagram. Scope limits the damage; detection limits the duration. Neither substitutes for the other.
A worked structure
Section titled “A worked structure”Concrete arrangements are easier to evaluate than principles. For an organisation of thirty engineers across four product areas:
Base permission: Read. Everyone can see everything, which is usually right internally and makes code discoverable. Nobody can push anywhere by default.
One team per product area, each with Write on its own repositories. Membership follows the organisational structure, so joining a team is the access grant.
A platform team with Write on shared infrastructure repositories, and Maintain — not Admin — on
the product repositories, so it can adjust settings without holding deletion rights.
Admin held by two named people plus a break-glass team of two more. Four total, in an organisation of thirty. If that number feels low, that is the point: it is the count that decides how strong every ruleset is.
Automation as one GitHub App per function — a release App, a dependency-update App, a deployment App — each installed on the repositories it needs, with the narrowest permission set that works. No automation runs on a person’s token.
Production deploys through environments with the owning team as required reviewers, so deploying is a permission held by a team rather than a side effect of being able to push.
Outside collaborators enumerated monthly, because they sit outside every mechanism above.
Nothing in that structure is unusual. What makes it work is that each grant has an obvious owner and an obvious removal path, so the review in the next section has something to check rather than something to reconstruct.
Reviewing access
Section titled “Reviewing access”A review that produces action rather than a report answers four questions.
-
Who are the admins? Organisation owners and repository admins. This is the shortest list and the highest leverage. For each: do they need it, today?
-
Which identities can write, and via which team? Include outside collaborators, who sit outside the team structure and are easily missed.
-
What machine identities exist? Deploy keys, App installations, tokens with organisation access, Actions secrets. For each: what is it for, who owns it, when does it expire?
-
What can bypass the rules? The bypass lists on your rulesets. A short answer here is worth more than a long list of rules.
The API makes this tractable rather than a click-through exercise:
{/* Everyone with direct access to a repository, and their permission level */}gh api /repos/OWNER/REPO/collaborators --jq '.[] | {login, role_name}'{/* Deploy keys, which belong to nobody and expire never */}gh api /repos/OWNER/REPO/keys --jq '.[] | {title, read_only, created_at}'{/* Outside collaborators across the organisation */}gh api /orgs/YOUR_ORG/outside_collaborators --jq '.[].login'Run these on a schedule and diff the output. A new deploy key appearing between two runs is a fact worth knowing; discovering it a year later during an incident is not.
Offboarding
Section titled “Offboarding”The list, in order of what is most often missed:
- Remove the person from organisation membership — this handles team-derived access
- Check outside collaborator entries, which are separate from membership
- Revoke their personal access tokens, which do not disappear with the account
- Identify deploy keys they added, which belong to nobody and survive
- Identify automation running under their token, which will break and then be replaced with somebody else’s token unless it becomes an App
- Rotate any shared credential they had access to
- Reassign anything they owned — Apps, webhooks, integrations
Steps 4 and 5 are the ones that matter and the ones no checklist generated by the platform will give you. They are also the strongest argument for the earlier rules: if automation runs as an App and servers use deploy keys nobody added ad hoc, offboarding is step 1 and nothing else.
Where enterprise controls fit
Section titled “Where enterprise controls fit”Larger organisations have mechanisms above the ones described here, and it is worth knowing what they change rather than treating them as a separate world.
SAML single sign-on moves authentication to your identity provider. A GitHub account is still the identity that acts, but access to the organisation depends on an active session with the IdP — which means deactivating someone centrally removes their organisation access without touching GitHub. Note the boundary: personal access tokens and SSH keys must be authorised for SSO separately, and that authorisation is what a departure needs to revoke.
SCIM provisioning goes further, creating and deactivating organisation membership from the directory. It converts offboarding step 1 from a manual action into a consequence of the HR system, which is the only version of offboarding that reliably happens.
Enterprise policies set floors that organisation owners cannot lower — two-factor requirements, repository visibility limits, Actions restrictions, permitted authentication methods.
None of this reaches the machine identities. Deploy keys, App installations and tokens in Actions secrets are unaffected by SCIM, and that asymmetry is worth stating plainly: the more mature your human identity management, the larger the proportion of your remaining risk that is machine identities.
Auditing what happened
Section titled “Auditing what happened”Access control decides what is possible. The audit log records what occurred, and it covers administrative actions including permission changes, protection rule modifications and ruleset bypasses.
Two things determine whether it is a control or an archive:
Somebody reads it. An audit log nobody looks at has exactly the security value of no audit log, with additional storage cost. Route the events that matter — bypasses, permission escalations, App installations — somewhere a human sees them.
Retention outlasts detection. Incidents are found weeks or months after they begin. A log that covers less time than your detection latency cannot answer the question you will ask of it.
Common mistakes
Section titled “Common mistakes”Base permissions set to Write. Every member can push to every repository, and the team structure is decorative.
Individual grants instead of teams. Nobody can answer “who has access”, and offboarding becomes a search across repositories.
Classic tokens with repo scope. One credential for every repository the account can see,
frequently with no expiry.
Automation running on a person’s token. Their access, their audit trail, their departure breaking production.
Deploy keys nobody owns. Not covered by any offboarding process, expire never, appear in no team.
GITHUB_TOKEN write by default. Every job, including ones that only run tests, holds write access
to the repository.
Production access as a repository role. Environments with required reviewers exist precisely so that deploying is a separate permission from pushing.
Temporary access with no expiry mechanism. “Temporary” is a claim about intent, not a control.
Mental model
Section titled “Mental model”Access is a graph: identities on one side, resources on the other, edges granted by teams, roles, tokens and installations. Security work is keeping the edges few, explicit and short-lived — and knowing that the machine identities, which nobody reviews, are where the graph quietly grows.
What you learned
Section titled “What you learned”- Authentication establishes who is acting; authorization determines what they may do
- Base permissions set a floor for the entire organisation and should be None or Read
- Write access includes editing workflow files, which is code execution with the repository’s credentials
- Admin access includes changing the rules, which is why admin count is the key metric
- Grant through teams; treat a direct individual grant as a signal the structure is wrong
- Deploy keys, account SSH keys and classic PATs never expire, and two of the three belong to a person
- GitHub Apps give automation its own identity, scoped permissions and short-lived tokens
- Production access belongs in an environment with required reviewers, not in a repository role
- Temporary access needs a mechanism — an enforced expiry, a break-glass identity, or a scheduled removal
- Offboarding must cover outside collaborators, tokens, deploy keys and automation running as the person
Exercise
Section titled “Exercise”Use a test organisation and a disposable repository.
-
Check the organisation’s base permission. Predict: can a member with no explicit grant clone a repository they were never added to?
-
Create a team, give it Write on the repository, and add a member. Confirm access. Remove them from the team and confirm it is gone.
-
Grant a second person Read directly. Run the
collaboratorsAPI command and observe how the two grants are reported differently. -
Add a read-only deploy key. Predict: does removing the person who added it remove the key?
-
Set the repository’s default workflow permissions to read-only. Add a workflow that tries to push a commit. Predict: what error appears, and at which point?
-
Add
permissions: contents: writeto that job and confirm it now succeeds. -
Create an environment with yourself as a required reviewer, put a secret in it, and add a job that uses it. Predict: does the job start, or wait?
-
Delete the repository.
Related lessons
Section titled “Related lessons”Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.