Most developers think of GitHub Actions as automation.
Build the app.
Run tests.
Deploy.
Publish a package.
Maybe send a notification.
But a workflow is not just a script runner.
Depending on how you configure it, a GitHub Actions job may be able to:
- read your repository
- modify code
- create releases
- publish packages
- access secrets
- talk to cloud providers
- deploy production
- comment on pull requests
- change repository state
That means your CI pipeline is not just automation.
It is part of your security boundary.
And many workflows have more authority than the developers maintaining them realize.
Start With GITHUB_TOKEN
Every GitHub Actions job receives a GITHUB_TOKEN.
GitHub creates it automatically for the job, and its permissions determine what the workflow can do inside the repository.
That makes this small section of YAML extremely important:
permissions:
contents: read
Without thinking about permissions explicitly, developers can easily give a workflow more authority than it actually needs.
The safer idea is simple:
Give each workflow the minimum permissions required to complete its job.
If a job only needs to check out code and run tests, it probably does not need write access.
A Test Job Should Not Be Able to Publish Anything
Consider a basic CI workflow:
name: CI
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: npm ci
- run: npm test
Ask yourself:
What does this job actually need?
Probably:
permissions:
contents: read
That is it.
It does not need permission to:
- create releases
- modify issues
- publish packages
- write repository contents
A useful mental model is:
Permissions belong to the job, not to your general trust in GitHub Actions.
The Most Dangerous Workflow Trigger May Surprise You
One trigger developers should understand very carefully is:
pull_request_target
It looks similar to:
pull_request
But the security model is very different.
A normal pull_request from a fork gets strong restrictions: GitHub withholds repository secrets and gives the workflow a read-only token.
pull_request_target, on the other hand, runs with the trust of the base repository and can receive repository secrets and a read/write GITHUB_TOKEN.
That is useful for things like:
- labeling pull requests
- triage
- authenticated checks
But it becomes dangerous if you combine it with untrusted code.
The “Pwn Request” Pattern
This is the dangerous shape:
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install
- run: npm test
At first glance, this looks reasonable.
You want to test the contributor's code.
But now you have:
Untrusted pull request code running inside a privileged workflow.
That code could modify:
package.json- install scripts
- test scripts
- build scripts
- Makefiles
- configuration files
And GitHub explicitly warns against executing fork code in a privileged pull_request_target workflow.
The problem is not checkout itself.
The problem starts when you execute what you checked out.
npm install Is Code Execution
This is another thing developers sometimes forget.
When you run:
npm install
you are not necessarily just downloading files.
A dependency may have install scripts.
Your own repository may have lifecycle scripts.
Build tools may execute configuration.
So if untrusted code can modify dependency or build configuration, running:
npm install
inside a privileged job may effectively mean:
Execute code controlled by the pull request.
The same idea applies beyond npm:
make
pip install
./gradlew build
cargo build
docker build
CI commands are execution boundaries.
Treat them that way.
Secrets Change the Risk Completely
Imagine your workflow contains:
env:
API_KEY: ${{ secrets.API_KEY }}
Now anything executing in that job may potentially interact with that secret.
GitHub recommends using least-privilege credentials because jobs and actions can access secrets available to the workflow.
A useful question is:
If this step were compromised, what could it steal or modify?
That question should influence how you structure jobs.
Separate Untrusted Code From Privileged Operations
Suppose your pipeline needs to:
- test a pull request
- publish something afterward
Do not automatically put everything in one giant privileged job.
Think in trust boundaries.
For example:
Pull request code
↓
Build + test
No secrets
Read-only token
↓
Artifact
↓
Trusted workflow
↓
Publish / deploy
The first job handles untrusted code.
The later job handles privileged operations.
That separation makes the system much easier to reason about.
Third-Party Actions Are Dependencies Too
Developers carefully review npm packages.
Then write:
uses: some-user/some-action@v2
without thinking twice.
But third-party actions execute inside your workflow.
A compromised action could potentially access repository secrets or use the workflow's GITHUB_TOKEN. GitHub recommends pinning actions to a full commit SHA when you need an immutable reference.
Instead of relying only on:
uses: vendor/action@v2
a more controlled setup can pin the exact commit:
uses: vendor/action@3c2f...full-commit-sha
Tags are convenient.
But tags can move.
A full commit SHA is immutable.
Your Workflow Has a Dependency Tree
Most developers think their dependency tree is:
application
├── react
├── express
└── postgres client
But there is another one:
CI pipeline
├── actions/checkout
├── setup-node
├── deployment action
├── security scanner
└── custom third-party actions
Those dependencies deserve review too.
Because they may run with more privilege than your application code.
Long-Lived Cloud Credentials Are Often Unnecessary
A common deployment setup looks like this:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Now you have long-lived credentials stored in GitHub.
A better model, where supported, is short-lived authentication.
GitHub Actions supports OpenID Connect so workflows can request short-lived identity tokens and exchange them with cloud providers instead of storing permanent cloud credentials.
Conceptually:
GitHub workflow
↓
OIDC identity
↓
Cloud provider verifies workflow
↓
Temporary credentials
↓
Deploy
Now there may be no permanent cloud secret sitting in the repository configuration.
That is a major improvement.
Self-Hosted Runners Change the Threat Model
GitHub-hosted runners are usually temporary.
Self-hosted runners may not be.
If your runner also has access to:
- internal networks
- Docker sockets
- production databases
- cloud metadata
- SSH keys
- mounted files
then executing untrusted code there can become much more dangerous.
GitHub specifically warns that compromised runners can expose secrets and other resources available to the runner environment.
Ask:
What can this machine reach that GitHub itself cannot?
That includes network access, not just stored credentials.
A Green Build Does Not Mean a Safe Workflow
Developers often see:
✓ Build passed
✓ Tests passed
✓ Deployment passed
and assume everything is fine.
But workflow security is not about whether the commands succeeded.
It is about:
What authority did those commands have while they were running?
A perfectly successful workflow can still be badly designed.
Audit These 8 Things in Your GitHub Actions Today
1. Token Permissions
Search your workflows for:
permissions:
If it is missing, ask whether you should define it explicitly.
Prefer the minimum required permission.
2. pull_request_target
Search:
pull_request_target
If you find it, understand exactly why it exists.
Be especially careful if that workflow checks out and executes pull request code. GitHub is actively tightening policy around this event in public repositories because of its security implications.
3. Secrets
Search for:
secrets.
For every secret, ask:
Does this job really need it?
Do not make secrets available simply because a later step might use them.
4. Third-Party Actions
Review every:
uses:
Ask:
- who maintains this?
- do we still need it?
- is it pinned?
- what permissions does the job have?
5. Publishing
Search for:
npm publish
docker push
gh release
terraform apply
kubectl
aws
gcloud
az
Anything that changes an external system deserves extra scrutiny.
6. Untrusted Input
Be careful when inserting values from issues, pull requests, branch names, commit messages, or other external sources into shell commands.
The workflow file is code.
Untrusted strings should be treated like untrusted input anywhere else.
7. Self-Hosted Runners
Ask what the runner can access.
A runner with internal network access has a very different blast radius from an isolated temporary runner.
8. Build and Deploy Separation
If possible, separate:
build
from:
deploy
Your build job should not automatically inherit production authority just because deployment happens later.
A Safer Mental Model
Do not think:
GitHub Actions runs my CI.
Think:
GitHub Actions runs code with an identity, permissions, credentials, network access, and external capabilities.
That changes how you review a workflow.
A workflow becomes much closer to a small production service than a simple YAML file.
Final Thought
Most developers would never give every application endpoint administrator access.
But we sometimes give CI pipelines broad permissions because:
“It is just our build workflow.”
It is not.
Your workflow may have the ability to modify repositories, access secrets, publish packages, deploy infrastructure, and authenticate to production systems.
So the next time you open:
.github/workflows/
do not only ask:
Does this pipeline work?
Ask:
What could this pipeline do if one step became untrusted?
That question may be far more important.
Top comments (2)
The part about treating CI as a security boundary is probably the biggest takeaway for me.
A practical example is a
pull_request_targetworkflow that checks out the PR head and then runs something as innocent-looking as:The dangerous part isn't
checkoutitself. Once the workflow executes the PR's code, things likepackage.jsonlifecycle scripts, a modifiedMakefile, test helpers, or even a dependency install can become an execution path for attacker-controlled code.Now add a write-capable
GITHUB_TOKENor a production credential to that runner and the CI job has effectively become a privileged execution environment.That's why I prefer separating the trust boundaries: let the untrusted PR workflow build/test with minimal permissions, produce an artifact, and have a separate trusted workflow handle deployment with narrowly scoped credentials or OIDC.
The pipeline being green is not the same thing as the pipeline being safe. The interesting question is always: what can this runner do if the code it executes is hostile?
That question changes how you design the whole CI/CD architecture.
The pwn-request pattern is worse self-hosted. On GitHub's runners it costs you the repository; on your own runner it's a shell on infrastructure you own, with the Docker socket and cloud credentials beside it. The token is the smaller half.
Our docs at Krova Cloud say the same for runners on a Cube: private repositories only, because anyone who can open a PR can run code on the runner. A runner that's outbound-only needs no inbound port at all, which removes most of the surface.
permissions: contents: read is the highest-value line here.