DEV Community

Cover image for Your GitHub Actions Workflow Has More Power Than Most Developers Realize
Robert Adamson
Robert Adamson

Posted on

Your GitHub Actions Workflow Has More Power Than Most Developers Realize

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ask yourself:

What does this job actually need?

Probably:

permissions:
  contents: read
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It looks similar to:

pull_request
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

inside a privileged job may effectively mean:

Execute code controlled by the pull request.

The same idea applies beyond npm:

make
Enter fullscreen mode Exit fullscreen mode
pip install
Enter fullscreen mode Exit fullscreen mode
./gradlew build
Enter fullscreen mode Exit fullscreen mode
cargo build
Enter fullscreen mode Exit fullscreen mode
docker build
Enter fullscreen mode Exit fullscreen mode

CI commands are execution boundaries.

Treat them that way.


Secrets Change the Risk Completely

Imagine your workflow contains:

env:
  API_KEY: ${{ secrets.API_KEY }}
Enter fullscreen mode Exit fullscreen mode

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:

  1. test a pull request
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

a more controlled setup can pin the exact commit:

uses: vendor/action@3c2f...full-commit-sha
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But there is another one:

CI pipeline
├── actions/checkout
├── setup-node
├── deployment action
├── security scanner
└── custom third-party actions
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

If it is missing, ask whether you should define it explicitly.

Prefer the minimum required permission.


2. pull_request_target

Search:

pull_request_target
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

from:

deploy
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
sinarezaei profile image
Sina Rezaei •

The part about treating CI as a security boundary is probably the biggest takeaway for me.

A practical example is a pull_request_target workflow that checks out the PR head and then runs something as innocent-looking as:

- uses: actions/checkout@v6
  with:
    ref: ${{ github.event.pull_request.head.sha }}

- run: npm install
- run: npm test
Enter fullscreen mode Exit fullscreen mode

The dangerous part isn't checkout itself. Once the workflow executes the PR's code, things like package.json lifecycle scripts, a modified Makefile, test helpers, or even a dependency install can become an execution path for attacker-controlled code.

Now add a write-capable GITHUB_TOKEN or 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.

Collapse
 
dhruv_malaviya profile image
Dhruv Malaviya •

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.