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_TOKENat 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 -
writepermissions carried at the level of the job that needs them, never the workflow
# No rights by default, each job asks for the minimumpermissions: {}
jobs: test: permissions: contents: read publish: permissions: contents: read packages: writeRepository 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.1Assessing 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-actionsecosystem - Update pull requests reviewed regularly
version: 2updates: - 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 intorun: - 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_targetunless 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 secretsname: PR Buildon: 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 secretsname: PR Buildon: 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 buildworkflow_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: choicewhen the value domain is known - Inputs consumed through
env:, never interpolated intorun: - Allowed users restricted when the action is sensitive
on: workflow_dispatch: inputs: environment: type: choice options: - staging - productionCommand 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.titlegithub.event.issue.bodygithub.event.pull_request.titlegithub.event.pull_request.bodygithub.event.comment.bodygithub.head_refgithub.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-minutesset on every job
jobs: build: runs-on: ubuntu-24.04 timeout-minutes: 30Environments
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
productionenvironment with required approval - Allowed branches limited (
mainonly for production) - A
wait timerconfigured 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
productionenvironment - 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.gzVerification
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
gh attestation verify my-app.tar.gz --owner my-org --repo my-appAudit 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-Permissionsscore at 10/10 -
Pinned-Dependenciesscore at 10/10 - No
Dangerous-Workflowdetected
scorecard --local . --checks Token-Permissions,Pinned-Dependencies,Dangerous-WorkflowCheckov
Checkov analyses the workflows as infrastructure code and spots misconfigurations: broad permissions, unpinned actions, exposed secrets.
- Workflow scan with no critical error
checkov -d .github/workflows/ --framework github_actionsactionlint
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
actionlint .github/workflows/*.ymlCode 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.
- Permissions explicit and minimal
- Actions pinned by SHA
- No command injection possible
- Secrets used through
env:only - No dangerous
pull_request_target -
timeout-minutesset 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 throughenv:, 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
- Supply chain attacks on GitHub Actions: the real cases behind each line of this checklist.
- OpenSSF Scorecard: measuring the result against the 19 checks of the reference.
- Verifying attestations: the last gate, checking before deployment what the pipeline actually produced.