Securing GitHub Actions: Permissions, Pinning, and Workflow Injection Prevention
Problem
GitHub Actions is the most widely used CI/CD platform, but its security model is scattered across dozens of documentation pages. Default configurations are dangerously permissive: GITHUB_TOKEN has write access to the repository, actions referenced by tag can be hijacked by the maintainer, pull_request_target workflows execute attacker-controlled code with repository secrets, and environment secrets are accessible to any workflow in the repository.
Threat Model
- Adversary: Malicious contributor (fork-based attack), compromised third-party action maintainer, or attacker with write access to the repository.
- Objective: Extract repository secrets, inject code into builds, modify workflows for persistent access.
- Blast radius: All secrets in the repository; all deployments triggered by workflows.
Configuration
Minimal Permissions on Every Workflow
# Default: no permissions. Each job declares what it needs.
name: Build and Test
on: [push, pull_request]
# Top-level: restrict GITHUB_TOKEN to read-only by default
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: make build
- run: make test
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
# This job needs write permissions for deployment
permissions:
contents: read
id-token: write # OIDC
packages: write # Push to GHCR
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Push to registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
Pin Actions by SHA (Not Tag)
Tags can be force-pushed. An action maintainer (or attacker who compromises their account) can change the code behind v4 at any time.
# BAD: pinned by tag - can be changed by the action maintainer
- uses: actions/checkout@v4
# GOOD: pinned by full SHA - immutable reference to a specific commit
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Automate SHA updates with Dependabot:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Dependabot will create PRs that update SHA pins
# when new versions of actions are released.
Prevent pull_request_target Injection
pull_request_target runs in the context of the BASE branch with full access to secrets, but can checkout and build code from the HEAD (attacker’s fork). This is the most dangerous GitHub Actions footgun.
# DANGEROUS: checks out attacker's code with repository secrets available
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
with:
ref: ${{ github.event.pull_request.head.sha }} # Attacker's code!
- run: make build # Runs attacker's Makefile with repo secrets
# SAFE: use pull_request_target only for labelling/commenting (no code checkout)
on: pull_request_target
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/labeler@v5 # Only reads PR metadata, doesn't checkout code
For workflows that need to build untrusted code AND access secrets, use workflow_run:
# Workflow 1: Build untrusted code (no secrets)
name: Build PR
on: pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- run: make build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: ./dist
# Workflow 2: Deploy/comment using artifacts from Workflow 1 (has secrets)
name: Post-Build
on:
workflow_run:
workflows: ["Build PR"]
types: [completed]
jobs:
deploy-preview:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
deployments: write
steps:
- uses: actions/download-artifact@v4
# Download artifacts from the untrusted build - safe because
# the artifact is a build output, not executable code in this context.
Environment Protection Rules
# Configure environments in GitHub repository settings:
# Settings → Environments → production
# - Required reviewers: 2 team members
# - Wait timer: 5 minutes
# - Deployment branches: main only
# - Environment secrets: PROD_AWS_ROLE_ARN (scoped to this environment only)
jobs:
deploy-production:
runs-on: ubuntu-latest
environment: production # Triggers approval gate
permissions:
id-token: write
steps:
- name: Deploy to production
run: |
# This only runs after 2 reviewers approve
echo "Deploying to production..."
Workflow File Protection
# CODEOWNERS - require security team review for workflow changes
# .github/CODEOWNERS
.github/workflows/ @your-org/security-team
.github/dependabot.yml @your-org/security-team
Enable branch protection rules:
- Require PR reviews for changes to
.github/workflows/ - CODEOWNERS review required (not just any reviewer)
- No force push to main
- Status checks must pass before merge
Secret Leak Detection
# Add to every workflow that handles secrets:
- name: Scan for secret leaks in output
if: always()
run: |
# Check that no secrets appear in the job log
# GitHub automatically masks secrets, but custom secrets
# or secrets in error messages may not be masked.
echo "Secret scan complete, review job output manually for any unmasked values"
For comprehensive secret scanning, integrate trufflehog or gitleaks:
- name: Scan for secrets in repository
uses: trufflesecurity/trufflehog@v3
with:
extra_args: --only-verified
Expected Behaviour
- Every workflow declares minimal
permissions; no workflow has default write-all - All third-party actions pinned by full SHA with Dependabot automated updates
- No
pull_request_targetworkflows check out untrusted code - Production deployments require environment approval (2 reviewers)
- Workflow file changes require CODEOWNERS (security team) review
- Secret scanning runs on every push
Trade-offs
| Control | Impact | Risk | Mitigation |
|---|---|---|---|
| SHA pinning | Verbose workflow files; frequent Dependabot PRs | Missing action security updates if Dependabot PRs are ignored | Review and merge Dependabot PRs weekly. |
| Minimal permissions | Must declare each permission per job | Jobs fail with 403 if a permission is missing | Iteratively add permissions as needed. |
| Environment approvals | Deployment speed reduced (human approval gate) | Bottleneck if approver unavailable | Require 2 approvers from a pool of 4+ people. |
| CODEOWNERS for workflows | Security team must review every workflow change | Bottleneck for rapid CI changes | Security team commits to 24-hour review SLA for workflow PRs. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Permission too restrictive | Workflow step fails with HttpError: Resource not accessible |
Job logs show 403; step that needs the permission is clear from the error | Add the specific permission to the job’s permissions block. |
| SHA-pinned action has CVE | Action vulnerable but pinned to old SHA | Dependabot PR for action update; GitHub advisory notification | Merge the Dependabot PR. Verify the new SHA matches the security fix. |
pull_request_target code injection |
Attacker’s code executes with repo secrets | Audit log shows unexpected workflow run from fork PR; secrets used in unexpected API calls | Remove pull_request_target trigger. Switch to workflow_run pattern. Rotate all exposed secrets immediately. |
| Approver unavailable | Production deployment blocked | Deployment queue backs up; team cannot ship | Pool of 4+ approvers across time zones. Emergency bypass with post-hoc review. |
When to Consider a Managed Alternative
Enforcing workflow standards across 20+ repositories requires governance tooling. Snyk (#48) provides GitHub integration scanning for secrets and vulnerabilities in CI config. GitHub Enterprise adds: secret scanning push protection (blocks commits containing secrets), code scanning (SAST in CI), and advanced audit logging. For teams outgrowing GitHub Actions: Buildkite (#94) provides managed orchestration with stricter runner isolation.
Premium content pack: GitHub Actions workflow templates. hardened build/test/deploy workflows with OIDC, minimal permissions, SHA-pinned actions, and environment protection. Includes Dependabot configuration and CODEOWNERS template.