Build the mental model
Your pipeline is a machine that checks out code and executes other people's programs with access to your secrets, your registry and a token that can write to your repository. Treat every `uses:` line as a dependency holding production privileges, because that is exactly what it is.
Start with pinning. `uses: some/action@v3` resolves a tag, and a tag is a mutable pointer: whoever owns that repository can move it to a new commit, and your next run executes code you never reviewed. A full forty-character commit SHA is immutable, so an upgrade becomes a reviewable pull request instead of something that happens to you overnight. Pinning does not make an action trustworthy — it makes it unchanging, which is the property that lets review mean anything at all.
Next, least privilege. `GITHUB_TOKEN` is generously scoped by default in older repositories. Declare `permissions: contents: read` at workflow level and grant more only in the specific job that needs it. Then a compromised step inside your test job cannot push commits or publish a release, because the token it can steal simply lacks the ability.
The sharpest edge is `pull_request_target`. It exists so that pull requests from forks can reach secrets — and that is precisely why it is dangerous. It runs in the context of the base repository with secrets available, so if you check out and execute the pull request's code you have handed an arbitrary stranger your credentials and your write token. Use `pull_request` for anything that runs untrusted code, and reserve `pull_request_target` for steps that never execute a contributor's changes, such as applying a label.
Finally, stop storing long-lived cloud keys. OIDC lets a workflow exchange a signed identity token for short-lived credentials from a role that trusts only your repository and branch, leaving nothing durable to leak.
ATTACK PATH THROUGH A COMPROMISED ACTION
----------------------------------------
attacker gains push access to a popular action's repository
|
v
moves the v3 tag to a new commit (tags are mutable)
|
v
your workflow: uses: some/action@v3
|
v
their code now runs INSIDE your job, with:
- every secret exposed to that job
- GITHUB_TOKEN and whatever scopes it holds
- network access to send all of it anywhere
WHERE THE PATH IS CUT
pin @<40-char-sha> the moved tag never reaches you
permissions: contents:read a stolen token cannot write or publish
OIDC short-lived creds nothing long-lived is left to steal
avoid pull_request_target untrusted PR code never sees secretsConnect it to a real scenario
Three things in this workflow deserve attention. First, `actions/checkout` and `actions/setup-node` are pinned to a full forty-character commit SHA rather than a tag; the version in the trailing comment is for humans, while the SHA is what actually runs. When you do this yourself, copy the SHA from that action's own releases page and verify it, rather than trusting one you found in a blog post. Dependabot can raise upgrade pull requests against SHA pins, which answers the usual objection that pinning leaves you stuck on old versions — it does not, it just makes the upgrade something you approve.
Second, permissions. The workflow declares `contents: read` at the top, and only the build job adds `id-token: write`, which is the single extra grant needed to request an OIDC token. The scan job inherits the narrower workflow-level default, so a compromised scanner cannot mint cloud credentials.
Third, credentials. `aws-actions/configure-aws-credentials` is used with `role-to-assume` and no access keys at all. The other half of that setup lives in AWS: the role's trust policy must restrict the subject to your specific repository and branch. If it trusts any GitHub repository, any workflow anywhere can assume it, and you have traded a stored key for a worse problem.
Try the working example
name: Secure Build
on:
pull_request:
branches:
- main
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
# Third-party actions are pinned to a full commit SHA so that a moved
# tag cannot silently change the code that runs with our secrets.
# Copy the SHA from the action's own releases page when upgrading.
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Get short-lived cloud credentials through OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_BUILD_ROLE }}
aws-region: ap-southeast-1
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: app:${{ github.sha }}
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Fail if any action is referenced by a mutable tag
run: ./scripts/check-action-pins.sh .github/workflows
- name: Audit dependencies for known advisories
run: ./scripts/audit-dependencies.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: security-report
path: reports/
retention-days: 30
On a pull request, build and scan run concurrently. The build job executes exactly the action code at the pinned SHA, so if that action's tag is later moved, your run is unaffected. The OIDC step obtains short-lived AWS credentials inside the job, which means no access key needs to be stored in the repository and the credentials expire when the job ends. Because `id-token: write` is granted only on the build job, the scan job cannot request an OIDC token at all, and both jobs inherit read-only repository access from the workflow-level `permissions` block. The scan job's pin check fails the run if any workflow file still references an action by a mutable tag, and the security report is uploaded either way because that step uses `if: always()`.5-minute try-it
List every `uses:` line in your `.github/workflows` directory and count how many reference a mutable tag. Pin the third-party ones — anything not published by GitHub itself — to a SHA first, since those are the ones you do not control. Then add `permissions: contents: read` at the top of every workflow and see which jobs break; each failure is telling you exactly which scope that job actually needs, which is information you did not have before. Finally, search for `pull_request_target` and check whether any such workflow checks out the pull request's head ref.
One important caution
Using `pull_request_target` and then checking out the pull request's head ref, so a stranger's fork code executes with your secrets and write token
Pinning to a major tag such as `@v4` and believing that is a pin — a major tag is a moving pointer that receives new commits continuously
GitHub Docs: Security hardening for GitHub Actions — CI/CD with GitHub Actions