Skip to content
Français
CI/CD & Automatisation medium

GitHub Actions workflow security checklist

35 min de lecture

Read this page in French

This page gathers the checkpoints to review before merging a GitHub Actions workflow. It is aimed at developers and DevSecOps teams who review pipeline code or run a security audit. Use it as a code review support: each ticked box closes a known attack path, permissions too broad, an unpinned action, an exposed secret, a command injection. The sections follow the order of a workflow: permissions, third-party actions, secrets, triggers, runners, then the tools that automate these checks.

What you will learn

  • Audit the permissions of the GITHUB_TOKEN at workflow and job level
  • Verify the pinning of third-party actions by commit SHA
  • Control how secrets are used and spot leaks in the logs
  • Detect risky triggers and command injections
  • Harden the self-hosted runners and the deployment environments
  • Automate these checks with Scorecard, Checkov and actionlint

Permissions

The GITHUB_TOKEN is a token generated automatically for every run. By default, its scope depends on the repository settings, and is often too broad. Reducing those rights is the first barrier: a compromised step can only overwrite the code or publish a package if the token allows it.

GITHUB_TOKEN

Declare the permissions: block explicitly. Start from permissions: {} at the workflow level, meaning no rights at all, then grant each job the strict minimum. Avoid write-all, which opens every scope at once.

  • The permissions: block is declared explicitly at the workflow level
  • permissions: {} by default, rights granted job by job
  • No permissions: write-all
  • write permissions carried at the level of the job that needs them, never the workflow
# No rights by default, each job asks for the minimum
permissions: {}
jobs:
test:
permissions:
contents: read
publish:
permissions:
contents: read
packages: write

Repository settings

The default permissions are also set on the repository side, in the Actions settings. Choosing the read-only mode guarantees that a workflow which forgets its permissions: block still inherits a restricted token.

  • Settings > Actions > General: "Read repository contents and packages permissions" selected
  • Workflows triggered by forks require a manual approval

Third-party actions

Every uses: runs code written by somebody else, with your secrets and your token. The compromise of tj-actions/changed-files in 2025 was a reminder: one booby-trapped popular action contaminates thousands of pipelines. Three reflexes close that vector: pin, assess, update.

Pinning

Reference every action by its full commit SHA (40 characters), not by a tag such as @v4, which can be moved to another commit. Add the readable version as a comment to track updates. The guide Pinning by SHA covers the manoeuvre.

  • Every action pinned by SHA (never @v1, @latest, @main)
  • A version comment after each SHA, for readability
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Assessing an action

Before adding a Marketplace action, check its author, its maintenance and its popularity. An obscure or abandoned action is an ideal vector. For critical actions, read the source code of the version you pin.

  • Marketplace actions assessed before use
  • Official actions (actions/*) or recognised publishers preferred
  • Source code reviewed for critical actions

Updating

Pinning freezes a version, including its flaws. Dependabot opens update pull requests automatically for the github-actions ecosystem, with the new SHA and the changelog. It remains for you to review them before merging.

  • Dependabot configured for the github-actions ecosystem
  • Update pull requests reviewed regularly
.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

Secrets

A secret leaked into a public log is compromised for good: it has to be revoked. Three angles to check: where to store them, how to use them without exposure, and how to detect the ones committed by mistake.

Storage

Put sensitive secrets into Environments rather than at repository level: there you can add protection rules and limit the allowed branches. For cloud providers, prefer OIDC over long-lived static credentials.

  • Sensitive secrets kept in Environments, not at repository level
  • No secret in clear text in the workflow files
  • OIDC used for the cloud providers (no static credentials)

Usage

Always pass a secret through an env: block, never directly in run: nor as a visible command argument. Echoing a secret, even accidentally, writes it in clear text into the run logs.

  • Secrets passed through env:, never interpolated into run:
  • No echo ${{ secrets.XXX }} and no deliberate printing of a secret
- name: Trigger the deployment
run: |
curl --fail --silent --show-error \
-X POST https://api.netlify.com/api/v1/sites/blog-stephane-robert/builds \
-H "Authorization: Bearer $NETLIFY_TOKEN"
env:
NETLIFY_TOKEN: ${{ secrets.NETLIFY_TOKEN }}

Detection

A committed secret stays in the Git history even after the file is deleted. A scanner such as Gitleaks in the pipeline, plus GitHub secret scanning, catch those leaks early, before they reach a shared branch.

  • Gitleaks or an equivalent scanner wired into the pipeline
  • GitHub secret scanning enabled on the repository

Triggers and events

The trigger decides which code runs and with which rights. Some events, notably pull_request_target, mix untrusted code with secrets: that is where the most serious flaws live.

pull_request_target

pull_request_target runs with the secrets of the target repository, even for a pull request coming from a fork. Checking out then running the pull request code amounts to running a stranger's code with your keys. The guide pull_request_target covers the safe workarounds.

  • No pull_request_target unless genuinely needed
  • If used: never a checkout of the pull request code followed by running it
# ❌ pull_request_target running a fork's code: RCE with your secrets
name: PR Build
on: pull_request_target
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run build
# ✅ The pull_request trigger: the fork's code runs with no access to the secrets
name: PR Build
on: pull_request
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm run build

workflow_dispatch

The manual workflow_dispatch trigger accepts inputs. Restrict them with type: choice (a closed list) where possible, and treat them as untrusted data: through env:, never interpolated straight into run:.

  • Inputs declared as type: choice when the value domain is known
  • Inputs consumed through env:, never interpolated into run:
  • Allowed users restricted when the action is sensitive
on:
workflow_dispatch:
inputs:
environment:
type: choice
options:
- staging
- production

Command injection

Command injection is the most common workflow flaw. Interpolating with ${{ }} a piece of data a third party controls, an issue title, a branch name, a comment, inside a run: block allows arbitrary code execution on the runner. The counter fits in one rule: pass the data through an environment variable.

# ❌ Vulnerable: the issue title is injected into the shell
- run: echo "Issue: ${{ github.event.issue.title }}"
# ✅ Secured: the title goes through an environment variable
- run: echo "Issue: $ISSUE_TITLE"
env:
ISSUE_TITLE: ${{ github.event.issue.title }}

The following contexts are fed by external data and must never be interpolated into a run: block:

  • github.event.issue.title
  • github.event.issue.body
  • github.event.pull_request.title
  • github.event.pull_request.body
  • github.event.comment.body
  • github.head_ref
  • github.event.*.user.login

Runners

The runner is the machine that executes your jobs. On GitHub-hosted runners, GitHub recreates it from scratch for every job. On a self-hosted runner, it is your infrastructure, and a malicious job can persist there from one run to the next.

Self-hosted

A self-hosted runner must never serve a public repository nor handle pull requests from forks: that would offer arbitrary code execution on your network. Prefer ephemeral runners, destroyed after each job.

  • Never attached to a public repository
  • Ephemeral runners, destroyed after each job
  • Separate pools per environment (dev / staging / prod)
  • A run account with no sudo privileges
  • Centralised logs and monitoring in place

GitHub-hosted

For most cases, GitHub-hosted runners are the best choice: isolated, disposable, maintenance-free. Add a timeout-minutes to avoid zombie jobs eating your minutes endlessly.

  • GitHub-hosted runners preferred by default
  • timeout-minutes set on every job
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 30

Environments

A GitHub Environment represents a deployment target, staging, production. It carries protection rules and dedicated secrets, which makes it the right place to compartmentalise production access.

Protection

Require a manual approval on the production environment and limit the allowed branches to main. A wait timer leaves a window to cancel a deployment triggered by mistake.

  • A production environment with required approval
  • Allowed branches limited (main only for production)
  • A wait timer configured to allow a cancellation

Secrets

Keep the production secrets only in the production environment. Separate tokens per environment limit the impact of a leak: a compromised staging token does not touch production.

  • Production secrets isolated in the production environment
  • Different tokens for each environment

Attestations and provenance

An attestation is cryptographic proof tying an artefact to its build workflow and to its source code. It lets you verify, before deploying, that a binary really is the one you think.

Generation

Generate a provenance attestation and an SBOM (Software Bill of Materials, the inventory of components) for every release. The guide Attestations covers the setup.

  • A provenance attestation generated for every release
  • An SBOM generated and published with the release
- name: Generate the provenance attestation
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: my-app.tar.gz

Verification

An unverified attestation protects nothing. Check it before any deployment with gh attestation verify. The guide Verifying attestations shows how to enforce it in CI/CD.

  • Attestation verified before every deployment
Fenêtre de terminal
gh attestation verify my-app.tar.gz --owner my-org --repo my-app

Audit tools

These checks can largely run automatically. Three complementary tools cover the syntax, the configuration and the overall security posture of a repository.

Scorecard

OpenSSF Scorecard grades a repository on a series of security criteria: token permissions, dependency pinning, dangerous workflows. Aim for 10/10 on the Actions-related checks.

  • Token-Permissions score at 10/10
  • Pinned-Dependencies score at 10/10
  • No Dangerous-Workflow detected
Fenêtre de terminal
scorecard --local . --checks Token-Permissions,Pinned-Dependencies,Dangerous-Workflow

Checkov

Checkov analyses the workflows as infrastructure code and spots misconfigurations: broad permissions, unpinned actions, exposed secrets.

  • Workflow scan with no critical error
Fenêtre de terminal
checkov -d .github/workflows/ --framework github_actions

actionlint

actionlint validates the syntax of the workflows: invalid expressions, unknown keys, shell errors inside run: blocks. It is the fastest safety net to put in place; see the actionlint guide to wire it into pre-commit.

  • Workflows syntactically valid
Fenêtre de terminal
actionlint .github/workflows/*.yml

Code review

The checklist earns its keep in a pull request review. A modified workflow deserves the same attention as an application code change: it is the part holding the rights and the secrets of the repository.

Before merging a workflow

Six blocking points to check systematically before approving a pull request touching a .github/workflows/ file.

  1. Permissions explicit and minimal
  2. Actions pinned by SHA
  3. No command injection possible
  4. Secrets used through env: only
  5. No dangerous pull_request_target
  6. timeout-minutes set on every job

Regular review

The security of a pipeline decays over time: new actions, permissions that widen, SHAs falling behind the fixes. A periodic audit catches that drift.

  • A quarterly audit of every workflow
  • Dependabot pull requests handled without piling up
  • Actual permissions compared with the necessary ones

A model secured workflow

Here is a complete CI/CD workflow applying this entire checklist: minimal permissions, pinned actions, OIDC for the deployment, timeouts and concurrency. Use it as a starting point.

name: Secure CI/CD
on:
push:
branches: [main]
pull_request:
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Check out the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
cache: npm
- name: Install the dependencies
run: npm ci
- name: Run the tests
run: npm test
build:
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Check out the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
cache: npm
- name: Install the dependencies
run: npm ci
- name: Build the application
run: npm run build
- name: Upload the build artefact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
deploy:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 10
environment: production
permissions:
contents: read
id-token: write
steps:
- name: Download the build artefact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Authenticate to AWS through OIDC
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: eu-west-1
- name: Sync the build to S3
env:
S3_BUCKET: ${{ vars.S3_BUCKET }}
run: aws s3 sync dist/ "s3://$S3_BUCKET"

Key points

  • A checklist is only worth something when applied in review: run it over every pull request that changes a workflow.
  • Minimal permissions and SHA pinning close the two most exploited attack paths.
  • A secret never travels through run: directly: always through env:, so it does not leak into the logs.
  • Command injection is neutralised by passing external data through an environment variable.
  • A self-hosted runner on a public repository exposes your infrastructure: keep it for private repositories and prefer ephemeral ones.
  • Scorecard, Checkov and actionlint automate these checks; wire them into the pipeline so you do not depend on a human review.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn