Before you start
Most GitHub Actions security breaks fall into two categories: workflows that run untrusted code with production secrets, and repositories that leak their ability to ship code to attackers. This post covers the specific configuration changes that prevent both. We assume you're running CI/CD workflows in Actions today and want to know what's actually exploitable.
1. Permissions block is not optional
Almost every Actions workflow we audit has permissions: write-all inherited from the default
or no permissions block at all. Repositories created before February 2023 default to read-write for
GITHUB_TOKEN; newer repos default to read-only, but most orgs have legacy repos that still grant write-all.
This means any code running in that job can modify branch protection rules, approve PRs, or delete releases.
Add a permissions block at the top level of your workflow and set each permission to the minimum required.
name: CI
on: [push, pull_request]
permissions:
contents: read
pull-requests: read
checks: write
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: npm test Per-job permissions override the workflow-level block. Use this to grant elevated
permissions only to jobs that need them — a deployment job might have contents: write, but your test job should have contents: read.
2. Pin third-party actions by SHA
A tag like actions/checkout@v4 is a moving target. The maintainer can push a new commit,
retagging v4 to point to malicious code, and your next workflow run pulls the poisoned version. This
is not theoretical — it has happened.
Pin every third-party action to a specific commit SHA:
- uses: actions/checkout@0ad4b8fadaa221de15d46a0165f0c60caee9f45b # v4.1.4
- uses: actions/setup-node@60edb3dd545a775178fdef7a76f52496191713dd0 # v4.0.2 Use a comment with the version so you know what you pinned to. Automated tools like Dependabot can
update these pins regularly. Inline GitHub Actions in .github/workflows/ are your code
and don't need pinning.
3. Use OIDC for cloud credentials
Do not create long-lived AWS access keys, GCP service account keys, or Azure credentials for Actions. Use OpenID Connect to request short-lived credentials on each job run. GitHub's OIDC token is signed and includes job context (repository, ref, actor, run ID).
Configure your cloud provider to trust GitHub's OIDC issuer and validate the token before issuing credentials. For AWS:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1 The IAM role's trust policy binds the assume-role operation to your GitHub repository and specific branches. If a private key leaks, it cannot be used to obtain AWS credentials without the issuer's signature.
4. Reusable workflows as security boundaries
A reusable workflow is a shared workflow that other workflows call. The caller workflow can be pull_request with untrusted code; the reusable workflow that runs deployment logic should
be in a separate file with stricter controls.
Reusable workflows run in the context of the called-from repository. This means you can enforce environments, concurrency, and permissions restrictions in the
reusable workflow without relying on the caller to set them correctly.
# .github/workflows/deploy.yml (reusable)
on:
workflow_call:
inputs:
environment:
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@...
- uses: aws-actions/configure-aws-credentials@v4 If you have a test.yml workflow that could be poisoned by a PR, have it call a
reusable deploy.yml workflow instead of doing deployment directly. The environment protection rules
apply to the reusable workflow's job, not the caller.
5. The pull_request_target footgun
pull_request_target runs the workflow from the base branch, not the PR branch. This is
useful for workflows that need to write to the repository (labeling, commenting), but it's a credential
leak vector if you're not careful.
The trap: the PR author's code is checked out, but the base branch's secrets are available. A malicious PR can exfiltrate your deployment keys.
# BAD: don't do this
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- run: npm test
# Now the PR's malicious npm package can read ${{ secrets.DEPLOY_KEY }} If you must use pull_request_target, check out the base branch (default behavior),
run only read operations (no npm install from the PR's code), and pass no secrets to
the job. For most workflows, use pull_request instead.
6. Environment protection rules and required reviewers
An environment is a named target (production, staging, canary) that can have its own secrets and
deployment branch rules. Create environments in your repository settings and configure deployment_branches to restrict which branches can deploy.
For production, require manual review before each deployment:
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh GitHub will pause the job and require approval from a designated reviewer before the deploy job can proceed. You can restrict which accounts are allowed to approve.
7. Secret scanning and Dependabot configuration
Enable secret scanning in repository settings. GitHub scans your commits for API keys, tokens, and SSH keys that match known patterns. The scanner is free for public repositories and a paid feature for private ones.
Configure Dependabot to scan your Action dependencies and open PRs when updates are available. In .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly" Dependabot pulls are opened against your default branch and do not automatically trigger deployments. This gives you a chance to review the change before it ships.
8. Runner isolation — threat models
GitHub-hosted runners are ephemeral Linux, macOS, or Windows virtual machines. Each job gets a fresh VM. Self-hosted runners are long-lived machines in your network or owned by you.
The risk split: on GitHub-hosted runners, an attacker can only steal secrets from the current job and leak build artifacts. On self-hosted runners, an attacker can persist between jobs, compromise the host OS, and access the network behind the runner.
If you must use self-hosted runners, do not run untrusted code on them. This means no public forks of your repository should be able to trigger workflows on self-hosted runners. In workflow files, use:
jobs:
deploy:
runs-on: [self-hosted, production]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' This ensures that pull_request events (which include forks) cannot trigger the deployment job on self-hosted infrastructure.
9. Branch protection that prevents pipeline poisoning
Enforce branch protection rules on your main production branches. Require pull requests for all changes, require status checks to pass, and require a code review.
The critical rule: Require a code review from someone other than the author before merging. This prevents a compromised GitHub account from pushing code directly to main.
Use Require approval of the latest reviewable commit to invalidate approvals if the
code changes after review. Use Require branches to be up to date before merging to prevent
race-condition merges.
For Actions specifically: require that the workflow file itself has been reviewed and approved. This prevents a PR author from modifying the workflow to exfiltrate secrets and running it in a single commit.
The short version
Set an explicit permissions block on every workflow and grant only the minimum
required permissions. Pin third-party Actions to commit SHAs, not tags. Use OIDC to request
short-lived cloud credentials instead of storing long-lived keys. Deploy via reusable workflows
with environment protection rules that require manual review. Avoid pull_request_target unless you understand the secrets leak. Configure Dependabot for regular
Action and dependency updates. Never run untrusted code on self-hosted runners. Enforce branch protection
with code review and status check requirements on your main branches.
Want us to harden your Actions setup?
We audit your workflows, lock down permissions, wire OIDC, and hand you a configuration your team can maintain. Senior DevSecOps engineers, no SDRs.