Skip to content
Search

Securing GitHub Actions Pipelines Against Supply Chain Attacks

On a regular Tuesday morning, your engineering team pushes code, the pipeline runs like it always does, and somewhere in those automated logs, your AWS access keys, your GitHub tokens, your RSA private keys, are being quietly printed out and collected by someone you have never met. You are not notified. No alarm goes off. GitHub does not send an email. Your pipeline shows green.

That is not a hypothetical. That is exactly what happened to over 23,000 teams in March 2025.

I have spent years working at the intersection of software engineering and security, where pipelines were moving over a billion dollars in monetary transactions, and more recently, building Nexloy, a self-hosted deployment platform where I had to make every security decision from scratch. What I am about to share is the pattern I have watched attackers exploit again and again, the mistakes I have seen brilliant teams make, and the seven specific things that actually work when you need to lock down a GitHub Actions pipeline.

By the end of this article, you will understand exactly how these attacks happen, why your pipeline is more exposed than you probably think, and what you can do about it today. Some of these changes take five minutes. Some take an afternoon. All of them matter.

Stay with me.

The Attack That Woke the Industry Up

In March 2025, engineering teams at over 23,000 companies opened their workflow logs and found something horrifying. Their most sensitive credentials were sitting there, printed in plain text, for anyone with access to those logs to read. We are talking about AWS (Amazon Web Services) access keys, GitHub PATs (Personal Access Tokens, which are like passwords that give programmatic access to your codebase), RSA private keys (cryptographic keys used to prove identity and encrypt data), and npm tokens (credentials for publishing software packages).

The cause was not a sophisticated zero-day exploit. It was not a nation-state attacker breaching GitHub’s own servers. It was something almost embarrassingly simple. A popular open-source GitHub Action called tj-actions/changed-files had been quietly compromised.

Here is how it worked. The attackers gained access to the repository hosting that Action and then did something sneaky. They went back and updated the version tags, which are labels like @v35 or @v44 that teams use to reference a specific version, to point at their malicious code instead of the original. No announcements. No pull requests. No alerts. Just a silent swap. Every team whose pipeline said “use version 44 of this Action” was now running the attackers’ code, and that code had one job: find every secret in your pipeline environment and write it to the logs.

This vulnerability was assigned CVE-2025-30066. CVE stands for Common Vulnerabilities and Exposures, which is the official tracking system for publicly disclosed security flaws. It became the largest GitHub Actions supply chain attack on record.

But here is the detail I want you to hold onto, because it is the heartbeat of this entire article. The teams that had pinned their Actions to a specific commit SHA were not affected. A commit SHA (Secure Hash Algorithm) is a unique cryptographic fingerprint of the exact code they had reviewed, and it cannot be faked or silently moved. One configuration decision separated the impacted from the protected.

That single fact changed how I think about pipeline security. Let me explain why, and then walk you through the seven things you can do to protect your own systems.

This Was Not a One-Off

Before I get into the solutions, I want to make sure you understand the scale of what is happening, because CVE-2025-30066 was a headline, but it was not an outlier.

In December 2024, Ultralytics, the company behind the YOLO (You Only Look Once) computer vision library, was hit by an almost identical attack. YOLO is one of the most widely used AI projects in the world, with nearly 60 million downloads on PyPI (the Python Package Index, which is the main place people download Python libraries from). Attackers exploited a misconfigured workflow trigger called pull_request_target to run their own code inside Ultralytics’ pipeline. The result was a cryptominer, which is software that secretly uses your computer’s resources to generate cryptocurrency for someone else, bundled into four official releases and shipped to users around the world.

In February 2026, Trivy, Aqua Security’s own open-source security scanning tool used by thousands of organisations to find vulnerabilities in their pipelines, had its GitHub Actions workflow exploited to steal an organisation-wide PAT (Personal Access Token) that had access to 33 internal workflows. The tool had been quietly vulnerable since October 2025. A separate scanner had even flagged the issue in November 2025, three months before an attacker found it first.

When a security company’s own security tool gets compromised through its CI/CD (Continuous Integration and Continuous Deployment) pipeline, that tells you something important. This is not about negligence or inexperience. It is about a systemic gap in how the industry thinks about pipeline security.

Across all of these incidents and others like them, three root causes appear again and again.

  1. Actions pinned to tags instead of commit SHAs, because tags can be silently changed to point anywhere
  2. Misuse of the pull_request_target trigger, a workflow setting that accidentally grants fork contributors access to internal secrets
  3. Overly permissive GITHUB_TOKEN scopes, where the automatic credential GitHub issues to every workflow is far more powerful than it needs to be

Now let us talk about how to fix them.

Why GitHub Actions Is Such an Attractive Target

GitHub Actions is genuinely brilliant. It lets you automate almost anything. You can run tests, build Docker containers, deploy to cloud servers, and send notifications, all triggered by events in your repository. Its marketplace has thousands of pre-built Actions, which are reusable automation components contributed by the community.

That same ecosystem is also what makes it dangerous.

When you add a line like uses: some-org/some-action@v2 to your workflow file, you are doing something that should give you pause. You are downloading and executing code from the internet, written by someone you have probably never met, inside an environment that has access to every secret your pipeline uses. Your cloud credentials. Your deployment keys. Your database passwords.

Most teams apply far less scrutiny to this than they would to adding a new npm (Node Package Manager, the tool used to install JavaScript libraries) dependency. And the npm ecosystem has had its own supply chain disasters. At least with npm, you tend to look at the package before running it.

There is another layer to this. GitHub Actions automatically tries to redact known secrets from your workflow logs, but that protection only works if GitHub knows what your secrets are. The moment an attacker controls the code running inside your pipeline, they can extract secrets and send them outward in ways that bypass that redaction entirely.

The GITHUB_TOKEN, which is the automatically generated credential that every workflow receives, is another underappreciated risk. In many repositories, this token defaults to write permissions across the entire repository. A compromised workflow with that token can push code to your main branch, modify releases, or quietly alter your deployment scripts. Most developers I have spoken to have never looked at what their GITHUB_TOKEN is actually allowed to do.

And then there is pull_request_target. This is a workflow trigger created to allow workflows to safely respond to pull requests from forks, which are copies of your repository made by outside contributors. The problem is it runs in the context of the base repository, meaning it has access to your secrets and write permissions, even when the code triggering it came from a complete stranger’s fork. This is the root cause of the Ultralytics attack, the Trivy attack, and the initial phase of the tj-actions compromise that started by targeting Coinbase.

7 Hardening Techniques That Actually Work

1. Pin Actions to Commit SHAs, Not Tags

I will say this plainly. If you take only one thing from this article, let it be this.

A tag like @v4 is just a label. Anyone with write access to that repository can move it to point at completely different code without telling you. A commit SHA is a unique fingerprint generated from the exact contents of the code at a specific point in time. It cannot be faked or silently moved. Once you pin to a SHA, you are guaranteed to always run exactly the code you reviewed.

Before, vulnerable to silent tag manipulation:

steps:
  - uses: actions/checkout@v4
  - uses: tj-actions/changed-files@v44

After, locked to exact verified code:

steps:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
  - uses: tj-actions/changed-files@d6e91a0f7b4c9b8e5a3d2c1f0e9d8c7b6a5f4e3d # v44.5.1

The human-readable comment next to the SHA is important. It tells future you and your colleagues what version this is supposed to be. Use tools like Dependabot or Renovate to automate the process of updating these SHAs when new legitimate versions are released.

This single change is what separated the protected teams from the 23,000 affected by CVE-2025-30066.

2. Use OpenID Connect Instead of Long-Lived Stored Secrets

For a long time, the standard way to give your pipeline access to cloud services like AWS (Amazon Web Services) or GCP (Google Cloud Platform) was to create a set of credentials, copy them into GitHub’s secrets manager, and have your workflow read them out at runtime.

The problem is that those credentials are static. They do not expire on their own. If they are ever leaked through a compromised Action, a misconfigured log, or a breach of GitHub itself, they remain valid until someone manually goes in and rotates them. In a security incident, that window can be hours or days.

OIDC, which stands for OpenID Connect and is an open standard for authentication that allows systems to verify identity without exchanging long-lived passwords, solves this. Instead of storing credentials, your workflow requests a short-lived token from GitHub that proves who it is. Your cloud provider is configured to trust that token and exchange it for temporary credentials that expire when the job ends.

Before, static credentials stored in GitHub secrets:

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: us-east-1

After, OIDC authentication with no stored credentials needed:

permissions:
  id-token: write   # Allow the workflow to request an OIDC token
  contents: read

steps:
  - name: Configure AWS credentials via OIDC
    uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6542e0d4c84cc4db732e71e04 # v4.0.2
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions-role
      aws-region: us-east-1

Even if an attacker somehow captured that OIDC token mid-workflow, it is worthless the moment the job finishes. There is nothing to steal from your secrets manager because there is nothing stored there to steal. This is the same principle I applied when building the GitHub integration layer in Nexloy, where encrypted GitHub OAuth tokens are stored rather than long-lived deploy keys sitting in plain text.

3. Restrict GITHUB_TOKEN to the Minimum It Needs

Think of the GITHUB_TOKEN like a master key that GitHub automatically hands to every workflow when it starts. In many default configurations, that key has write access to your entire repository. A compromised workflow with that key can push to your main branch, publish releases, and modify your deployment configuration, all without your knowledge.

The fix is to start from zero permissions and then explicitly add back only what each job actually needs.

First, go to your repository settings under Settings, then Actions, then General, then Workflow permissions, and set it to read-only as the default.

Then in your workflow files, declare permissions explicitly:

# Lock down the whole workflow by default, nothing is permitted
permissions: {}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read    # Only needs to read code
      packages: write   # Needs to push a container to the registry
      id-token: write   # Needs to request an OIDC token
    steps:
      # build steps here...

  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read    # This job genuinely only needs to read code
    steps:
      # deploy steps here...

When you do this, even if one job in your workflow is compromised, the attacker is limited to what that specific job was permitted to do. A breach of your build job cannot automatically become a write to your main branch.

4. Treat Workflow Files Like Production Code, Because They Are

Here is something I have noticed over the years. Engineering teams will require two or three reviewers to approve any change to their core application code. But a change to a .github/workflows/ file, which controls what runs in their entire CI/CD pipeline, sometimes gets merged with a single click.

That inconsistency is a problem. Workflow files are your production infrastructure. A change to a workflow file can introduce a new third-party Action, relax permissions, or add a new deployment step. It deserves the same scrutiny as your application code.

The simplest way to enforce this is through CODEOWNERS, a GitHub feature that lets you specify which team must review changes to specific files or directories:

# .github/CODEOWNERS
# Any changes to workflow files require review from the security team
/.github/workflows/    @your-org/security-team @your-org/platform-engineering
/.github/actions/      @your-org/security-team

With this in place, no workflow change, including the introduction of a new third-party Action, can be merged without a review from someone who knows what to look for.

Additionally, use GitHub’s Environments feature to add a human approval gate before any workflow deploys to production. This means even a fully compromised workflow has to stop and wait for a real human to click approve, which is often enough time to spot the anomaly.

5. Use Automated Tools to Scan Your Workflows

Manual review of workflow files is valuable but does not scale. Add automated scanning to your security toolchain so problems are caught before they ever reach your main branch.

Zizmor is a static analysis tool built specifically for GitHub Actions. It reads your workflow files and flags issues like dangerous use of pull_request_target, script injection vulnerabilities (places where untrusted input could be interpreted as code), and overly permissive token configurations:

# Run it locally to audit your workflows right now
pip install zizmor
zizmor .github/workflows/

# Or add it as a step inside your CI pipeline itself
- name: Scan workflows with Zizmor
  uses: woodruffw/zizmor-action@db2cb61655abc17c8f8f00bd9e3d13b91cba0ef9 # v1.5.0
  with:
    args: --format sarif .github/workflows/

StepSecurity Harden Runner takes a different approach. Instead of analysing your workflow files, it monitors what your workflow actually does at runtime. It can detect and block unexpected outbound network connections from your pipeline steps, the kind of monitoring that would have caught the tj-actions attack sending your secrets to an attacker’s server while your pipeline was still mid-run.

The principle here is the same as application security. It is always better to fail at CI than to fail in production.

6. Mirror Critical Actions and Use a Private Container Registry

For environments where the stakes are high, such as fintech, healthcare, or critical infrastructure, depending on public GitHub Actions is an unnecessary risk. The solution is to fork the Actions you depend on into your own organisation’s repositories, review the code, and pin to SHAs within your fork. That way, you are not dependent on an external maintainer’s account security.

For container images, which are the self-contained software packages that most modern deployments are built around, use a private registry like Harbor instead of pulling from Docker Hub or public GHCR (GitHub Container Registry) at runtime.

# Build and push your image to your own Harbor instance
- name: Build and push container image
  uses: docker/build-push-action@...
  with:
    push: true
    tags: harbor.internal.yourdomain.com/myapp:${{ github.sha }}

# In your Dockerfile, reference your Harbor instance, not Docker Hub
FROM harbor.internal.yourdomain.com/base/node:20-alpine

When every image flows through your own Harbor instance, you can enforce signing, which is cryptographic proof that the image came from your build pipeline and has not been tampered with. Nothing gets deployed unless it was built and approved through your controlled process.

7. Enforce Branch Protection Rules and Deployment Gates

Every security measure I have described so far is most effective as one layer in a defence-in-depth approach, meaning multiple independent barriers that an attacker would need to defeat simultaneously.

Branch protection rules and environment gates are your last line of defence. Even if a workflow is compromised, these controls can stop it from doing lasting damage:

# In your deployment workflow
jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production   # This triggers a mandatory approval gate
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Deploy to production
        run: ./deploy.sh

Configure your production environment in GitHub settings to require manual approval from a named person or team before the deployment proceeds, a deployment branch restriction so only your main branch can deploy to production, and a wait timer for especially sensitive operations that gives you time to cancel if something looks wrong.

A compromised workflow that needs human approval to reach production is a compromised workflow that gets caught.

How I Applied All of This in Nexloy

Everything I have described in this article is not just theory I picked up from documentation. It is the set of decisions I had to make in practice while building Nexloy, a self-hosted deployment platform I have been working on quietly for a while now.

I am not ready to share the full details yet because the open source launch is coming soon, and I want to do it properly. But here is enough context to show you why these principles mattered so much to me as a builder.

The core problem Nexloy was built to solve is that small engineering teams are stuck between two painful extremes. On one side you have manual SSH scripts, scattered environment variables, and deployment processes that live only in someone’s head. On the other side you have Kubernetes, which is powerful but brings enormous complexity that most small teams do not actually need. Nexloy sits in the practical middle ground: Docker Compose, Nginx, and GitHub Actions on servers you own, with a proper dashboard, deployment history, secret management, and resource bindings all in one place.

Building it meant I had to think hard about every security decision because the platform itself holds the keys to your production servers. A deployment pipeline that manages other deployment pipelines has no room for the mistakes I described earlier in this article. So the principles around encrypted credential storage, explicit deployment gates, secret redaction, and role-based access with MFA (Multi-Factor Authentication) were not optional extras I bolted on. They were the foundation.

The most important lesson I took from incidents like Trivy’s compromise is this: the best security is the kind that gets generated for you, not the kind you have to remember to configure. When Nexloy creates a GitHub Actions workflow for your project, the secure patterns are already in it. The burden should not fall on every individual team to get this right from scratch every time.

I will be writing a full dedicated post about Nexloy soon, covering the architecture, the decisions behind it, and how to get started. If you want to be the first to know when it drops, follow along at nexloy.dev , on GitHub at github.com/Nexloy, or on X at @NexloyDev. It is coming.

Your Quick Wins Checklist

Here are the things you can start on today. Most of these take under 30 minutes.

  • Search every workflow file for Actions referenced by tags like @v1, @v2, or @main and convert each one to a SHA pin with a comment showing the version
  • Go to Settings, then Actions, then General and set your default GITHUB_TOKEN permissions to read-only
  • Add permissions: {} to the top of every workflow file, then declare only what each job specifically needs
  • Search your repository for pull_request_target and review every instance against CISA (Cybersecurity and Infrastructure Security Agency) advisory guidance on this trigger
  • Install Zizmor and run it against your .github/ directory right now, before you close this tab
  • Create a .github/CODEOWNERS file that routes all workflow file changes to your security team for review
  • Enable Dependabot for Actions, so it automatically opens pull requests to update your SHA pins when new legitimate versions are released
  • Set up at least one production Environment in GitHub settings with a required reviewer
  • Enable secret scanning and push protection in your repository security settings, which catches accidental credential commits before they land on your main branch
  • Do a third-party Actions audit and for every Action you use, ask yourself: do I know who maintains this? Have I read the source code? Can I replace it with a SHA-pinned fork inside my own organisation?

If you genuinely cannot do all of these right now, do the first one. Pin your Actions to commit SHAs. It is the single change that would have protected the vast majority of the 23,000 repositories hit by CVE-2025-30066.

Closing Thoughts

I have been building and securing software systems for long enough to know that most breaches do not happen because a team was careless. They happen because of a gap between how seriously we treat application security and how seriously we treat pipeline security. The application gets pentest reviews, code audits, and threat modelling. The pipeline gets trusted blindly.

The attacks of 2024 and 2025 are the industry’s correction. Supply chain attacks against GitHub Actions pipelines increased sharply across that period, hitting not just small teams but well-resourced companies and, in Trivy’s case, security professionals who knew exactly what the risks were.

The encouraging thing is that the defences are not exotic. SHA pinning, OIDC, least-privilege tokens, and automated scanning; none of these require a large security team or a significant budget. They require intention, and they require treating your pipeline with the same seriousness you treat your code.

The teams that got hit were not failures. They were running behind the curve on a threat the industry is only now taking seriously at scale. You do not have to be.

If this was useful, I write about secure DevOps, API security, and real-world engineering lessons at vincenttechblog.com. I am also building Nexloy, an open source self-hosted deployment platform launching soon at nexloy.dev. And if you learn better by watching than reading, I break down topics like this in walkthroughs on my YouTube channel, where I am approaching 5,000 subscribers and have crossed 1 million views. Come find me. I would love to hear what you are building and what security challenges you are working through.

Discussion

Comments

Join the conversation below.

Join the discussion

Your email address will not be published. Required fields are marked.

This site uses Akismet to reduce spam. Learn how your comment data is processed.