GitHub Actions Security Best Practices
GitHub Actions workflows run code on real machines with access to your repository, secrets, and potentially production systems. A poorly configured workflow can expose credentials, execute malicious code, or allow unauthorized access to critical resources. This topic covers the security practices every team should apply to their workflows.
The Core Security Risks
Risk 1: Script injection
User input from pull request titles, comments, or
branch names gets inserted into a run: command.
Risk 2: Compromised third-party actions
An action you depend on gets updated with malicious code.
Risk 3: Overly permissive tokens
The workflow's GITHUB_TOKEN can read or write
more than it needs to.
Risk 4: Secret exposure
Secrets accidentally printed in logs or passed through
insecure channels.
Risk 5: Untrusted code execution
A pull request from a fork runs workflows with access
to your repository's secrets.
Principle of Least Privilege for Tokens
Every workflow run receives a GITHUB_TOKEN automatically. By default, this token has broad permissions. Restrict it to only what the workflow actually needs.
Set default permissions at the workflow level:
permissions:
contents: read ← Read-only access to repository contents
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
Grant elevated permissions only on the specific job that needs them:
permissions:
contents: read ← Workflow default: read only
jobs:
release:
permissions:
contents: write ← This job needs to create releases
packages: write ← This job needs to push packages
runs-on: ubuntu-latest
steps:
- run: gh release create v1.0
Common permission scopes
contents → Read/write repository files and releases
packages → Read/write GitHub Packages
pull-requests → Read/write pull request data
issues → Read/write issues
id-token → Needed for OIDC authentication
deployments → Read/write deployments
Pinning Actions to Commit SHAs
Using a version tag like @v4 trusts whoever controls that tag. If the action's repository is compromised and the tag is updated, malicious code runs in your workflow.
Vulnerable — uses mutable tag:
uses: actions/checkout@v4
Secure — pinned to an exact commit:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
Get the SHA by visiting the action's repository on GitHub, clicking the version tag, and copying the full commit hash from the URL. Tools like Dependabot and pin-github-action CLI automate this process and keep pins updated.
Preventing Script Injection
Script injection happens when user-controlled data gets executed as a shell command. The most common source is using GitHub context values directly in run steps:
Dangerous — the PR title could contain shell commands:
- run: echo "PR title: ${{ github.event.pull_request.title }}"
Safe — pass the value as an environment variable:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title: $PR_TITLE"
When you pass context values through environment variables, the shell treats them as plain string data, not executable commands. This blocks injection attacks completely.
Handling Pull Requests from Forks Safely
Pull requests from external contributors run on forked repositories. GitHub restricts fork PRs from accessing secrets by default — but teams sometimes accidentally expose them.
Safe behavior (default):
Fork PR triggers pull_request event
→ Workflow runs WITHOUT access to secrets
→ Safe for running tests
Risky behavior to avoid:
Using pull_request_target trigger with untrusted code
→ Workflow DOES have access to secrets
→ Attacker can extract your secrets via malicious PR
Use pull_request_target only with extreme caution and never check out untrusted code in jobs that run under that trigger.
Restricting Workflow Permissions with CODEOWNERS
Use GitHub's CODEOWNERS file to require review approval before workflow files can be changed:
# .github/CODEOWNERS
.github/workflows/ @security-team @devops-team
Any pull request that modifies workflow files requires explicit approval from security team members. This prevents unauthorized actors from adding malicious steps to your pipelines.
Auditing Workflow Permissions
GitHub's organization settings let you control which repositories can use GitHub Actions and which actions are permitted:
- Allow all actions — no restriction (not recommended for organizations)
- Allow GitHub-created actions only — only official actions from GitHub
- Allow select actions — specify an allowlist of permitted actions
For production teams, maintain an allowlist and review additions to it as part of your security process.
Environment Protection Rules
Apply protection rules to production environments to add a human approval gate before deployment:
- Required reviewers — specific people must approve
- Wait timer — enforces a delay between trigger and execution
- Branch filters — only allow deployments from specific branches
jobs:
deploy-production:
environment: production ← Triggers approval requirement
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
Security Checklist for Every Workflow
- Set
permissions: contents: readas the default at the top of every workflow - Grant elevated permissions only on specific jobs that need them
- Pin all third-party actions to commit SHAs
- Never use
${{ github.event... }}directly inrunsteps — use environment variables - Add CODEOWNERS for the
.github/workflows/directory - Require reviewers on production environments
- Rotate secrets regularly
- Use OIDC instead of long-lived access keys for cloud authentication
- Review all third-party action source code before adding it to workflows
