GitHub Actions is one of the most convenient ways to automate builds, tests, releases, and deployments. It is also one of the easiest places to accidentally hand attackers a path into your software supply chain when workflow trust boundaries are too loose.
That matters more now because recent supply chain incidents have followed the same pattern again and again: compromise the build path, steal a token, poison a release, and let downstream users do the rest.
This checklist focuses on the mistakes that show up most often in real workflows and the controls that give the biggest security return for the least operational pain.
Start with these five fixes
If you only change five things this week, make it these:
- Pin every third-party action to a full commit SHA, not a mutable tag like
@v3or@main. - Set
GITHUB_TOKENto read-only by default. - Never use
pull_request_targetin public repositories. - Never interpolate
${{ github.* }}values directly insiderun:steps. - Use OIDC for cloud authentication instead of long-lived cloud secrets.
These are not theoretical improvements. They close off some of the most common workflow-level paths used in GitHub Actions abuse cases, especially where an attacker can influence CI input or turn a compromised workflow into a release channel.
Why GitHub Actions keeps showing up in supply chain incidents
GitHub Actions is not insecure by design, but its defaults prioritize convenience and flexibility. That creates room for dangerous combinations: privileged triggers, mutable dependencies, over-broad tokens, and shell execution that treats attacker-controlled input like trusted data.
In open source especially, the blast radius is bigger than a single repository. A compromised workflow can expose publish credentials, tamper with build outputs, or ship a malicious package version that lands in downstream environments automatically.
That is why GitHub Actions security is no longer just CI hygiene. It is part of supply chain defense.
1. Lock down dangerous triggers
Avoid privileged pull request patterns
Do not use pull_request_target in public repositories. It runs in the base repository context and can expose secrets to code paths influenced by external contributors.
Be equally cautious with workflow_run. It can create a privilege-escalation chain where an untrusted upstream workflow feeds artifacts or outputs into a downstream workflow that has secrets or write access.
Also review workflows triggered by:
issue_commentissuespull_request_reviewpull_request_review_comment
These often run with secret access while still being influenced by untrusted user input.
Trigger checklist
- Ban
pull_request_targetin public repos. - Prefer direct
push-based deployment workflows overworkflow_runchains. - If
workflow_runis unavoidable, gate privileged jobs with checks such asgithub.event.workflow_run.event == 'push'. - Review comment- and issue-driven workflows as if they were fork PR entry points.
- Require maintainer approval before sensitive workflows run for outside contributors where possible.
2. Treat workflow input as hostile
Branch names, PR titles, issue bodies, and commit messages should all be treated as untrusted input. If they are inserted directly into a shell command, the runner may execute attacker-controlled content instead of handling it as plain text.
The safer pattern is to pass GitHub context values through env: first and only reference the environment variable inside the shell step.
# risky
- run: echo "Branch is ${{ github.head_ref }}"
# safer
- run: echo "Branch is $BRANCH"
env:
BRANCH: ${{ github.head_ref }}
Never write unsanitized attacker-controlled data into GITHUB_ENV or GITHUB_PATH, either. Those files affect later steps and can be abused to alter execution in subtle ways.
AI workflow note
If a workflow uses an LLM or agent, keep the token read-only and do not pass raw issue text, PR text, or commit text directly into prompts that can trigger tool use, shell execution, or file modification. Prompt injection in CI is still a supply chain risk, even if it starts as “just automation.”
Input checklist
- Never interpolate
${{ github.* }}directly inrun:commands. - Route untrusted values through
env:variables first. - Treat LLM output as untrusted before using it in commands, paths, or scripts.
- Never write unsanitized untrusted content to
GITHUB_ENVorGITHUB_PATH. - Keep AI-assisted workflows on read-only tokens unless there is a very narrow, audited exception.
3. Pin what runs and isolate what lands
Third-party actions should always be pinned to full SHAs. Tags and branches are mutable references, so a compromised maintainer account or action repository can silently change what your workflow executes on the next run.
Downloaded artifacts should be extracted into an isolated directory such as /tmp, not directly into the workspace. Otherwise, attacker-controlled content can overwrite scripts, files, or tooling that later steps trust.
Uploads should also be explicit. Patterns like path: . can accidentally sweep up .env files, generated configs, credentials, or other sensitive files.
Dependency and artifact checklist
- Pin all third-party actions to full commit SHAs.
- Prefer actions with fewer transitive dependencies.
- Pin package versions exactly instead of using floating ranges.
- Extract downloaded artifacts into isolated directories.
- Avoid broad upload patterns like
path: .. - Enable provenance checks and minimum release age where your tooling supports them.
4. Minimize token and secret blast radius
Secrets should be passed through environment variables rather than inline command arguments, because process arguments may be exposed through process listings on the runner.
Scope secrets as narrowly as possible: environment-level instead of repository-wide where possible, and step-level instead of job-level when only one step needs them.
Use OIDC for cloud access when available. Short-lived credentials are materially safer than static secrets sitting in repository settings waiting for the wrong workflow to expose them.
Secrets checklist
- Use environment variables for secrets, not inline command arguments.
- Scope secrets to GitHub Environments where possible.
- Expose secrets at the step level, not the whole job.
- Use OIDC for AWS, Azure, and GCP access when supported.
- Never print secret values, even in debug logs.
- Scope trusted publishing to a specific workflow file on a protected branch.
5. Be strict about runners and permissions
Do not attach self-hosted runners to public repositories. External pull requests can become code execution on infrastructure you control, which is a completely different risk profile from GitHub-hosted ephemeral runners.
Prefer ephemeral runners even in private environments. Long-lived runners can carry poisoned state, cached artifacts, or persistence from one job to the next.
Set GITHUB_TOKEN to read-only by default and add explicit permissions: blocks only to the jobs that truly need elevated access.
Runner and permission checklist
- Do not use self-hosted runners on public repos.
- Prefer ephemeral runners over persistent ones.
- Restrict runner egress where feasible.
- Set default
GITHUB_TOKENpermissions to read-only. - Add explicit job-level
permissions:only where needed.
6. Add repository-level guardrails
Repository settings matter as much as workflow YAML. Disable the ability for Actions to approve pull requests, restrict which actions can be used, and require CODEOWNERS review for changes under .github/workflows/.
Also monitor audit logs for suspicious self-hosted runner registration or unexpected repository creation. Those are the kinds of signals that often show up after a token is stolen or a workflow is abused.
Guardrail checklist
- Disable workflow-based PR approval.
- Restrict actions to verified creators or an allowlist.
- Require CODEOWNERS approval for
.github/workflows/changes. - Pair workflow reviews with branch protection.
- Monitor audit logs for runner registration and suspicious repo creation.
GitHub Actions is only one layer
A secure workflow is necessary, but it is not sufficient. Even a well-hardened pipeline can still pull a malicious or vulnerable dependency if package installs are trusted too easily.
That is where package-layer controls start to matter. ShieldedStack is a software supply chain security proxy for npm, NuGet, PyPI, and Maven that sits between developers or CI jobs and package registries, enforcing policy before packages are allowed into your environment.
Instead of relying on every workflow and every engineer to remember safe install patterns, ShieldedStack centralizes checks like CVE blocking, age- and popularity-based risk filters, and organization-wide allow and deny policies. That means a single misconfigured workflow is less likely to become the weak link that pulls in a bad dependency.
GitHub Actions hardening and ShieldedStack complement each other. Workflow security helps stop attackers from abusing the pipeline itself, while ShieldedStack helps stop risky dependencies from entering that pipeline in the first place.
If you are treating supply chain security seriously, you need both: hardening around how code is built and guardrails around what code is allowed in.
Copy-paste review checklist
Use this list when reviewing any repository that relies on GitHub Actions:
- No
pull_request_targetin public repos. - No unsafe
workflow_runprivilege chain. - No direct interpolation of untrusted
github.*values intorun:. - No untrusted writes to
GITHUB_ENVorGITHUB_PATH. - No artifact extraction into the main workspace.
- No
upload-artifactsteps that blindly sweep the whole repository. - No third-party actions pinned only by tag.
- No floating package versions in build-critical paths.
- No long-lived cloud credentials where OIDC is available.
- No secrets exposed broader than necessary.
- No self-hosted runners attached to public repositories.
- No write-capable
GITHUB_TOKENby default. - No workflow changes merged without security-aware review.