GitHub Actions Least Privilege: A Job-by-Job Audit
Audit GitHub Actions permissions by job, separate untrusted builds from deployment, and test the boundaries that keep CI credentials from becoming a liability.
GitHub Actions least privilege starts with a question the workflow file rarely answers: if this job runs hostile code, what can that code change? A pipeline that only needs to compile a pull request should have a very different answer from a pipeline that publishes a release.
My recommendation is to audit jobs by the authority they need, then by the inputs they execute. Permission settings matter, but they cannot rescue a design that intentionally runs untrusted code beside deployment credentials.
Inventory authority before editing YAML
For each job, record its trigger, checked-out revision, token permissions, secrets, runner type, and output consumers. Include scripts called indirectly through package managers. A line named “install dependencies” deserves the same scrutiny as a shell script copied into the workflow.
Use a small review table:
| Job | Required capability | Input trust | Proposed boundary |
|---|---|---|---|
| Pull-request tests | Read source | Contributor-controlled code | No deploy identity |
| Documentation preview | Upload a preview artifact | Contributor-controlled content | Separate preview account |
| Production deploy | Update one service | Approved release artifact | Protected environment |
This is a design example, not a statement about any particular repository. The point is to make surprising authority visible before choosing permissions. If the test job has a registry publishing token “because another step needs it,” split the responsibility before tightening the token.
Start with restricted token permissions
GitHub documents that an action can access github.token even when you do not explicitly pass GITHUB_TOKEN. Permissions can be set at workflow or job scope. Therefore, an absent env: GH_TOKEN line does not establish that a job has no repository authority. GitHub token authentication.
An illustrative starting fragment is:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Add reviewed, commit-pinned actions and test commands.
- run: echo "Configure the actual test steps here"
This is a permissions illustration, not a complete CI workflow. Give an issue-labeling job issues: write only if its implementation actually needs it. Give a release job package publishing authority only for its release responsibility. Treat every permission increase as a code change with an owner and explanation.
The review should also distinguish repository permissions from cloud permissions. Reducing GITHUB_TOKEN does not narrow an unrelated cloud key already available to the process. Audit the full credential set instead of treating one YAML block as the security perimeter.
Separate trusted deployment from untrusted execution
Imagine a contributor changes a test script so it reads every environment variable. If the job only sees disposable test configuration, the outcome differs substantially from a job holding a production token. This hypothetical is a useful review exercise because it requires no exploit sophistication.
A separate deployment job is a starting point, not an automatic trust guarantee. Ask how it chooses its artifact. Could an untrusted workflow upload an artifact with the expected name? Does deployment verify the producing run, repository, revision, and approval state? Could a build artifact contain executable hooks that the deployment job runs with elevated authority?
I would prefer a deployment step that promotes an identified artifact and invokes a narrow deployment API. If it needs to rebuild, reinstall arbitrary dependencies, or execute scripts from a contributor branch, revisit the boundary. Document exactly which input has been approved; “the CI run passed” can conceal several different commits and artifacts.
Review the interpreter boundary too
GitHub's secure-use guidance covers script injection, commit pinning for actions, and the danger of checking out untrusted code in privileged workflow contexts. A pull-request title is data; interpolating it directly into a generated shell script gives it a different role. GitHub secure-use reference.
During review, search for expressions embedded in run: blocks. For untrusted text, prefer passing data through an environment variable to a command that treats it as data, with appropriate shell quoting. This does not make arbitrary commands safe: a program may interpret its arguments as options or expressions. Follow the input all the way to the final interpreter.
Also review action updates as code updates. A familiar action name does not answer what a particular revision executes. Pinning creates a reviewable identity, while an update process keeps that identity from becoming permanent neglect.
Test the denied paths
A useful audit produces evidence beyond a cleaner file. In a disposable repository or test environment, exercise a contributor pull request, an approved branch push, and a deployment attempt from an unauthorized context. Confirm the intended action fails at the intended boundary.
Keep evidence minimal: the job name, attempted capability, denial result, and reviewed configuration revision. Do not print tokens to prove their presence. Inspect granted scopes and provider audit events instead.
When a denial breaks a legitimate workflow, resist restoring broad write access to get a green check. Identify the exact API operation and place that authority in the smallest appropriate job. The resulting friction is useful: it reveals assumptions the old pipeline kept implicit.
For adjacent context, read the blog's npm supply-chain analysis and hardcoded credential discussion. The operational lesson I would carry into an audit is simple: define what a compromised job can do before debating whether its scripts look trustworthy.