GitHub Actions workflows run with access to your secrets, your cloud credentials, and often write access to your repository. A compromised action or misconfigured workflow can exfiltrate secrets, push malicious code, or deploy backdoors to production.
These are the controls that matter.
What we're covering
- Pin actions by commit hash, not tag
- Minimal
permissionson every workflow - Secrets: what to store where
- OIDC instead of long-lived credentials
- Prevent pull request injection attacks
- Restrict which events can trigger production workflows
- Audit third-party actions before using them
- Scan workflows with static analysis
1. Pin actions by commit hash
Tags like actions/checkout@v4 are mutable. The owner can push a new commit to v4 at any time. If that account is compromised, every workflow using @v4 immediately runs attacker-controlled code.
Pin to an immutable commit SHA instead:
# Bad — mutable tag
- uses: actions/checkout@v4
# Good — immutable commit hash with tag comment for humans
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0To find the commit SHA for a tag, go to the action's GitHub repo → Tags → click the tag → copy the commit hash from the URL, or use the GitHub UI's "pin to SHA" button.
Automate it with Dependabot
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "github-actions"Dependabot will automatically open PRs to update pinned SHAs and includes the human-readable version in the commit message.
Even first-party actions like actions/checkout can be a vector — they run in your workflow with access to GITHUB_TOKEN. Always pin, always review what a new version of an action does before merging the Dependabot PR.
2. Minimal permissions
Every workflow gets a GITHUB_TOKEN automatically. By default, this token has read and write access to the repository. If your workflow only needs to check out code and run tests, that write access is unnecessary exposure.
Set the minimum permissions at the workflow level, then override per-job only if needed:
name: CI
# Deny all permissions at the workflow level
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
# Inherit read-only permissions from workflow level
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm ci && npm test
release:
needs: test
runs-on: ubuntu-latest
# Grant only what this specific job needs
permissions:
contents: write # needed to create a release
packages: write # needed to push to GHCR
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm run releaseCommon minimum permission sets:
| Job type | Permissions needed |
|---|---|
| Test only | contents: read |
| Deploy to cloud (OIDC) | id-token: write, contents: read |
| Create GitHub Release | contents: write |
| Push Docker image to GHCR | packages: write, contents: read |
| Comment on PR | pull-requests: write |
| Upload coverage report | checks: write |
3. Secrets management
What belongs in GitHub Secrets
GitHub Encrypted Secrets are fine for:
- API keys with limited scope
- Deployment tokens for non-production environments
- Webhook secrets
- NPM publish tokens
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publishWhat doesn't belong in GitHub Secrets
Long-lived production cloud credentials (AWS, GCP, Azure) should not live in GitHub Secrets. Use OIDC instead (see section 4).
Secret hygiene
# Never echo secrets — Actions masks them, but the habit matters
- run: echo "${{ secrets.MY_SECRET }}" # Bad
# Pass via environment, not command line arguments (visible in process list)
- name: Deploy
env:
API_KEY: ${{ secrets.DEPLOY_API_KEY }}
run: ./scripts/deploy.sh # Script reads $API_KEYGitHub Actions automatically masks any string that matches a secret value in workflow logs. But this masking can fail if the secret appears in a different encoding (base64, URL-encoded, etc.). Never rely solely on masking — treat every log line as potentially public.
Scoping secrets to environments
Use GitHub Environments to scope production secrets:
jobs:
deploy-production:
environment: production # Requires manual approval + only main branch can access
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
aws-region: us-east-1
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}4. OIDC instead of long-lived credentials
AWS, GCP, and Azure all support OpenID Connect (OIDC) federation with GitHub Actions. Instead of storing a static access key in secrets, your workflow requests a short-lived token that expires when the job ends.
AWS example
Step 1: Create the OIDC provider in AWS (one-time setup)
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1Step 2: Create an IAM role with a trust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main"
}
}
}
]
}Step 3: Use in workflow
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# No static credentials — role is assumed via OIDCThe StringLike condition in the trust policy restricts which branches and repositories can assume the role. A fork PR cannot assume your production deploy role.
5. Prevent pull request injection
The pull_request_target event is dangerous — it runs in the context of the target repository (with access to secrets) but checks out code from the PR branch (potentially from a fork). Attackers can submit a PR that modifies workflow files to exfiltrate secrets.
# Dangerous pattern
on: pull_request_target
jobs:
test:
steps:
- uses: actions/checkout@... # Checks out PR code
- run: npm ci && npm test # Runs attacker-controlled code with secret accessRules for pull_request_target:
- Never check out the PR branch code and run it
- If you need to comment on PRs or post status checks, run the sensitive steps in a separate job that doesn't touch PR code
- Prefer
pull_requestoverpull_request_target— it runs with a read-only token and no secrets for fork PRs
# Safe pattern for posting PR comments
on:
pull_request_target:
types: [opened, synchronize]
jobs:
# This job only posts a comment — it doesn't run PR code
comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Thanks for the PR!'
})6. Restrict which events trigger production deployments
Never deploy to production from arbitrary branches or on push to every branch:
# Bad — deploys from any branch push
on: push
# Good — only deploy from main, and only after tests pass
on:
push:
branches:
- main
jobs:
test:
# ...
deploy:
needs: test # Ensures tests pass first
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # Requires approval gate
steps:
# ...Also consider requiring branch protection on main:
- Require PR reviews before merging
- Require status checks to pass
- Require linear history
- Restrict who can push directly
7. Audit third-party actions
Before adding a third-party action (uses: some-org/some-action@...), check:
- Stars and activity — Is this maintained? When was the last commit?
- Verified creator — GitHub shows a verification badge on Actions by verified publishers
- Source code — Read the
action.ymland the scripts it runs. What permissions does it request? Does it make network calls? - Marketplace reviews — Sparse, but worth skimming
- Pinned SHA — Is the SHA in the
@SHAyou're pinning actually the release commit, not some other commit?
For actions that need elevated permissions, prefer using the official GitHub-authored version or the cloud provider's official action (AWS, Google, Azure all publish their own).
Some third-party actions upload telemetry or usage data. Review the source carefully before granting access to your secrets or cloud environments.
8. Static analysis with actionlint
actionlint is a static checker for GitHub Actions workflow files. It catches:
- Incorrect event/job/step field names
- Shell script errors in
runsteps - Expression injection vulnerabilities
- Unpinned actions
- Permission issues
# Install
brew install actionlint # macOS
# or
go install github.com/rhysd/actionlint/cmd/actionlint@latest
# Scan all workflow files
actionlint
# Specific file
actionlint .github/workflows/ci.ymlAdd to CI:
- name: Lint GitHub Actions workflows
uses: rhysd/actionlint@3a9c9f87d379d972bee1c59fb0faf45d50b5b6c4 # v1.7.7Also consider Zizmor for security-focused analysis:
pip install zizmor
zizmor .github/workflows/Checklist
- All third-party actions pinned to a commit SHA
- Dependabot configured for
github-actionsecosystem -
permissions: read-allor explicit minimal permissions on every workflow - No long-lived cloud credentials in GitHub Secrets — using OIDC instead
- Production deployments gated on
environment:with required reviewers - No
pull_request_targetchecking out PR branch code and running it -
actionlintrunning in CI - Branch protection enabled on main
What PatchVex checks
The VulnPilot CLI includes a workflow scanner that flags common misconfigurations:
$ vulnpilot scan --workflows .github/workflows/
Scanning 4 workflow files...
.github/workflows/ci.yml
✓ Permissions restricted
✗ actions/upload-artifact@v4 not pinned to SHA (line 34)
.github/workflows/deploy.yml
✓ OIDC configured
✓ Environment gates enabled
✗ pull_request_target with checkout detected (line 12) — HIGH RISK
2 issues found across 4 workflow files.