GitHub Actions Security Skill
A practical skill for auditing and hardening GitHub Actions workflows against supply chain attacks, secret theft, poisoned pipeline execution, and excessive token permissions.
Source reference
Based on: https://corgea.com/learn/github-actions-security-checklist
Also informed by: GitHub's 2026 Actions security roadmap, OpenSSF, OWASP CI/CD Top 10,
and GitHub Security Lab guidance on preventing pwn requests.
When the user shares a workflow file
- Read the YAML carefully.
- Check it against all 10 checklist areas below.
- Report findings grouped by risk area with severity (Critical / High / Medium / Low).
- Provide concrete fixed snippets for every finding.
- Summarize with a prioritized remediation list.
The five controls to always check first
Before anything else, verify these five — they cover the highest-impact failures:
| # | Control | Why it matters |
|---|---|---|
| 1 | GITHUB_TOKEN permissions set to read-only by default |
Limits blast radius of any compromised job |
| 2 | Third-party actions pinned to full commit SHA | Tags are mutable and can be hijacked |
| 3 | No pull_request_target for public repos or fork PRs |
Runs privileged context on untrusted code |
| 4 | All PR/issue/commit metadata treated as untrusted input | Branch names and PR titles can inject shell commands |
| 5 | OIDC used for cloud access instead of static secrets | Short-lived credentials sharply reduce secret theft value |
Full checklist (10 areas)
1. Organization and repository defaults
- Workflow token permissions set to read-only by default
- "Allow GitHub Actions to create and approve pull requests" is disabled
- Fork PR workflow approval set to "Require approval for all outside collaborators" in Settings → Actions → Fork pull request workflows
- Allowed actions restricted to GitHub-owned, verified creators, or an explicit allowlist
-
.github/workflows/covered by CODEOWNERS - Branch protection / repository rulesets require PR review, block force pushes, require status checks, dismiss stale approvals
2. Explicit workflow permissions
-
permissions:declared at workflow or job level (not relying on defaults) - Write scopes granted only to jobs that need them
-
id-token: writegranted only to OIDC jobs, not globally - Read-only validation separated from privileged publishing jobs
Minimal safe pattern:
permissions: {} # deny all at workflow level
jobs:
build:
permissions:
contents: read # grant only what this job needs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-commit-sha>
Trusted-build / privileged-publish split pattern:
Run untrusted code in a pull_request workflow (no secrets, read-only), then publish
only after CI passes via a workflow_run workflow that holds credentials:
# workflow-ci.yml — triggered by pull_request, no secrets, read-only
on: [pull_request]
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm test
# workflow-publish.yml — triggered only after CI passes on main
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
permissions:
contents: write
id-token: write
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm publish
3. Dangerous triggers and untrusted execution paths
-
pull_request_targetavoided in public repos; if used, never checks out or executes PR head code without maintainer review -
workflow_runworkflows check the upstream event before privileged actions -
issue_comment,pull_request_review,pull_request_review_commenttriggers audited for privilege -
pull_requestused for untrusted fork code (withholds secrets and write permissions by default) - Stale PRs closed or rebased after fixing a vulnerable workflow
workflow_run guard pattern: A workflow_run job runs with the base repo's full
permissions even when the upstream workflow was triggered by a fork PR. Always check the
upstream trigger and repo before taking any privileged action:
# ❌ Dangerous: privileged job runs regardless of what triggered upstream
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh # runs even for fork PRs!
# ✅ Guard with explicit checks before privileged steps
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Abort if triggered by a fork PR
if: |
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository.full_name != github.repository
run: |
echo "Refusing to run privileged job for fork PR"
exit 1
- run: ./deploy.sh # only reached for internal PRs or push events
4. Script injection prevention
Untrusted values include: branch names, PR titles/bodies, issue titles/bodies, labels, comments, commit messages, artifact contents from untrusted workflows.
Never interpolate untrusted context directly into run: blocks:
# ❌ Unsafe
- run: echo "Branch is ${{ github.head_ref }}"
# ✅ Safe — pass through environment variable
- run: echo "Branch is $BRANCH"
env:
BRANCH: ${{ github.head_ref }}
Checklist:
- Untrusted context passed through environment variables, not inline
${{ }} - Shell variables quoted when used
- Purpose-built actions preferred over inline shell for parsing untrusted input
- No untrusted data written to
GITHUB_ENVorGITHUB_PATH - No untrusted data written to
GITHUB_OUTPUTorGITHUB_STEP_SUMMARY - Artifact contents from PR workflows treated as attacker-controlled
5. Third-party action pinning
- All third-party actions pinned to full commit SHA (not a tag or branch)
- SHA verified to belong to the original repo, not a fork
- Preference for GitHub-owned or verified creator actions
- Transitive action dependencies reviewed — a pinned top-level action can still call nested actions by mutable tag; review the action's own
action.ymlforuses:references in composite steps, or usezizmor/actionlintto detect transitive unpinned references - Dependabot or Renovate configured to update SHAs through reviewed PRs
- Dependency graph and Dependabot alerts enabled for actions
Example:
# ❌ Mutable tag — can be moved by attacker
- uses: actions/checkout@v4
# ✅ Immutable SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
6. Secret hygiene
- Secrets scoped to environments with protection rules and required reviewers for production
- Secrets passed only to the specific step that needs them (not job-level
env:) - Secrets never passed as command-line arguments (use env vars)
- Secrets (including encoded or transformed forms) never echoed
-
secrets: inheritavoided in reusable workflows; only named secrets passed - Stale secrets rotated and removed
7. OIDC for cloud credentials
- Cloud trust policies configured with repo, branch, environment, and workflow constraints
-
id-token: writegranted only to the job that exchanges the token - Cloud roles limited to the exact operation (deploy, publish, etc.)
- Environment approvals required for production OIDC jobs
AWS IAM trust policy — scoped to a specific repo and environment:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub":
"repo:myorg/myrepo:environment:production",
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
⚠️ Avoid
StringLikewith wildcards unless you explicitly need flexibility.repo:myorg/*trusts every repository in the org — a compromised repo becomes a path to production credentials. Prefer exactrepo:org/repo:environment:nameclaims.
8. Runner hardening
- Self-hosted runners not used for public repositories
- Ephemeral or just-in-time runners used for sensitive private workflows (destroyed after one job)
- Runner groups separated by trust level (public / private / release / production)
- Runner network egress restricted to required endpoints only
- Runner registrations and unexpected public repo creation monitored
9. Artifacts, caches, and release workflows
- Artifact uploads scoped to explicit paths (not
path: .) -
.env, keys, credentials, and generated configs excluded from artifact uploads -
persist-credentials: falseset onactions/checkoutunless later steps need the token - Untrusted artifacts extracted outside the workspace to a temporary directory
- Secrets and release credentials not cached
- Caches avoided in privileged release workflows
- Provenance and attestations used for package releases where supported
Cache poisoning: Cache keys are deterministic (e.g. based on hashFiles('**/package-lock.json')),
so a fork PR can compute the same key, write a poisoned cache entry first, and have it
restored by your privileged release workflow. Mitigate by scoping cache keys to protected
branches and never restoring caches in release jobs:
# Scope cache key to the current branch — fork PRs write to a different key
- uses: actions/cache@<sha>
with:
path: ~/.npm
key: ${{ runner.os }}-${{ github.ref }}-${{ hashFiles('**/package-lock.json') }}
10. Continuous detection
- Workflow linting in CI using both complementary tools:
actionlint— syntax validation, shellcheck integration, expression type checking; catches malformed workflows and shell errorszizmor— security-focused; catches unsafe interpolation, dangerous triggers, unpinned actions
- OpenSSF Scorecard run to surface supply chain posture across the repo
- Code scanning enabled for workflow vulnerabilities (CodeQL supports common patterns)
- Action dependency changes reviewed in PRs like application dependency updates
- Alerts on failed attempts to use blocked actions or unpinned references
- Incident playbook documented: disable workflows → revoke tokens → rotate secrets → block actions → inspect runner activity
Rollout order for hardening many repos
- Inventory — workflows with write permissions, secrets, release jobs, self-hosted runners, public fork triggers
- Org defaults — read-only token permissions, action restrictions, CODEOWNERS, PR approval protections
- High-risk patterns —
pull_request_target,workflow_run, untrusted interpolation,GITHUB_ENV, broad secret exposure - Pin actions — convert mutable references to full SHAs, add Dependabot/Renovate
- OIDC migration — start with production deploy and package publish workflows
- Monitoring — workflow linting, dependency alerts, audit logs, runner registration alerts
Severity classification guide
| Severity | Examples |
|---|---|
| Critical | pull_request_target checking out fork head; secrets in run: args; unpinned third-party action on a release job |
| High | Script injection via ${{ github.head_ref }} in run:; write-all permissions; self-hosted runner on public repo |
| Medium | Missing permissions: block; secrets: inherit; no CODEOWNERS on workflows |
| Low | Broad artifact upload; missing persist-credentials: false; stale secrets not rotated |