We've all seen the CI/CD pipeline that worked perfectly in staging but somehow granted far too many permissions to a rogue dependency in production. GitHub Actions security: real-world lessons and examples shows you how to lock down your workflows so your automation serves you, not the other way around. In this article, we'll walk through the patterns and pitfalls we've encountered in real projects—and how to avoid them.
The rise of supply-chain attacks through Actions
GitHub Actions has become the default CI/CD choice for thousands of teams, and for good reason: it's tightly integrated, straightforward to set up, and free for public repositories. But that ease of adoption has come with a cost. We've watched teams spin up workflows, copy-paste actions from the marketplace, and grant blanket permissions to third-party code—all without realizing they've opened the door to sophisticated supply-chain attacks.
The threat is real. A compromised action that runs on push can exfiltrate secrets, modify your codebase, or push malicious code to production. The CISA and industry reports have documented cases where attackers pivot through CI/CD pipelines to reach customer data and production systems. The good news: most of these attacks are preventable with disciplined security hygiene.
Pin third-party actions to commit SHAs
When you reference an action in your workflow, you typically use a tag or branch: actions/checkout@v3 or docker/build-push-action@main. This is convenient, but it's also dangerous. A malicious actor who gains control of that repository can push a new commit to the tag or branch, and your workflow will silently pull the compromised version next time it runs.
The solution is to pin every third-party action to its full commit SHA. Instead of:
- uses: actions/checkout@v3Use:
- uses: actions/checkout@e2f20c305e34289a2e1786e265d5ecb4e04db151This way, even if the repository is compromised and that tag is rewritten, your workflow continues to run against the exact commit you audited and approved. The commit hash is immutable.
In practice, you can still use tags for discovery and review—just before merging to main, resolve them to SHAs. Many teams use a GitHub Action called actions/pin or a simple script to automate this:
#!/bin/bash
# Resolve action tags to commit SHAs in your workflow
grep "uses:" .github/workflows/*.yml | grep -v "#" | while read line; do
action=$(echo "$line" | cut -d: -f2- | xargs)
owner_repo=$(echo "$action" | cut -d@ -f1)
tag=$(echo "$action" | cut -d@ -f2)
# Fetch the SHA for this tag from the GitHub API
sha=$(curl -s "https://api.github.com/repos/${owner_repo}/commits/${tag}" | jq -r '.sha')
echo "$owner_repo@$tag => @$sha"
doneFor actions you maintain internally, you still benefit from pinning. A compromised CI/CD system is harder to recover from than a code commit, so treat your internal actions with the same rigor.
Least-privilege GITHUB_TOKEN permissions
Every GitHub Actions workflow receives a temporary GITHUB_TOKEN that grants it permissions to interact with your repository. By default, this token has broad read-write access: it can modify workflows, create releases, push commits, and more. A single compromised step in your workflow can abuse this token to wreak havoc.
The fix: explicitly declare the minimal permissions your workflow actually needs. At the top of your workflow file, add a permissions block:
name: Build and Test
on: [push, pull_request]
permissions:
contents: read
checks: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@e2f20c305e34289a2e1786e265d5ecb4e04db151
- run: npm testHere's what each permission means:
contents: read— can read the repository code, but not pushchecks: write— can create check runs (for test results)pull-requests: write— can comment on PRsdeployments: write— can trigger deployments (if your workflow deploys)
If your workflow doesn't need to write anything, use permissions: read-all. If it needs zero permissions (rare, but it happens), declare permissions: {}.
This approach limits the blast radius. If a dependency or a step is compromised, the attacker can only abuse the permissions you've explicitly granted. A contents: read token cannot push malicious commits or delete your repository.
We've seen teams go further and scope permissions per job:
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@e2f20c305e34289a2e1786e265d5ecb4e04db151
- run: npm test
deploy:
permissions:
contents: read
deployments: write
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@e2f20c305e34289a2e1786e265d5ecb4e04db151
- run: npm run deployThis way, the test job cannot deploy, even if it's compromised.
Be careful with pull_request_target
When you use the pull_request_target trigger instead of pull_request, your workflow runs against the base branch (usually main) instead of the PR branch. This is useful for workflows that need write access to your repository—like auto-labeling PRs or triggering required checks. But it's also a common vector for privilege escalation.
Here's the danger: an attacker can fork your repository, add a malicious workflow step that echoes the GITHUB_TOKEN or exfiltrates secrets, then open a PR from their fork. When your pull_request_target workflow runs, it does so against your main branch with your main branch's permissions and secrets. The attacker has now compromised your repository.
If you must use pull_request_target, follow these rules:
- Never check out the PR code directly. Check out the base branch instead.
- Restrict which workflows can use it. Use branch protection rules to require approval before a PR that modifies workflows can merge.
- Scope permissions tightly. A workflow that auto-labels PRs does not need
contents: write.
Safer pattern:
name: Auto-label PR
on:
pull_request_target:
types: [opened, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@e2f20c305e34289a2e1786e265d5ecb4e04db151
with:
ref: ${{ github.base_ref }}
- name: Apply labels
run: |
# Logic to label based on base branch, not PR code
echo "Labeling PR #${{ github.event.pull_request.number }}"Notice we explicitly check out the base ref, not the PR commit. We also declare that we only need pull-requests: write.
In some cases, you can replace pull_request_target entirely with a two-workflow pattern: the PR workflow does lightweight checks, then a separate workflow (triggered by the PR workflow completion) runs with higher privileges on the main branch.
Never echo secrets
This sounds obvious, but we've encountered it more often than you'd think: a developer adds a debugging step that echoes environment variables or command outputs, not realizing that GitHub Actions logs are searchable and often readable by team members or (in public repos) the entire internet.
Bad:
- name: Debug
run: |
echo "Database password: ${{ secrets.DB_PASSWORD }}"
echo "AWS key: ${{ secrets.AWS_ACCESS_KEY }}"Even if these steps are later removed, the logs are archived. An attacker searching GitHub for your domain or organization name might find these logs.
Better: use GitHub's add-mask command to mask secrets in logs, or simply avoid printing them:
- name: Validate credentials
run: |
# Don't echo the secret; just use it
curl -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" \
https://api.example.com/health
# If debugging is needed, log only non-sensitive parts
echo "API endpoint is reachable"If you absolutely must print something for debugging, use GitHub's ::add-mask:: command to redact it from logs:
- name: Debug with masking
run: |
echo "::add-mask::${{ secrets.SECRET_VALUE }}"
echo "Connecting with secret: ${{ secrets.SECRET_VALUE }}"The log will show: Connecting with secret: ***.
A more robust approach: if you need to debug with secrets in CI, use a separate debug environment with restricted access, not your main pipeline.
Common mistakes
- Using `latest` or `main` tags on actions. These can change at any time. Always pin to a commit SHA.
- Granting `contents: write` to every workflow. Most workflows only read code. Only deployments and release workflows need write access.
- Trusting internal actions without review. A compromised internal action is just as dangerous as a compromised public one. Pin them too.
- Storing secrets in workflow files. Use GitHub Secrets, and ensure they're scoped to the environments or branches that need them.
- Running `pull_request_target` workflows on PR code without review. This is a privilege-escalation vector. Always check out the base branch, not the PR code.
- Logging environment variables for debugging. Secrets end up in CI logs, which are often searchable and persistent.
Key takeaways
- Pin all third-party actions to their full commit SHA, not tags or branches.
- Declare minimal permissions for each workflow and job using the
permissionsblock. - Treat
pull_request_targetworkflows with extreme caution; restrict them to safe operations and always check out the base branch. - Never echo secrets, tokens, or API keys in workflow logs.
- Scope GitHub Secrets to specific environments and branches, not globally.
- Review and audit third-party actions before adding them to your workflows, even from well-known publishers.
- Use branch protection rules to require approval for workflow changes, especially in sensitive repositories.






Comments
Loading comments…