This article was first published on the Rokkhe blog on Hashnode. It was drafted with AI assistance and reviewed and edited by the Rokkhe team; all commands and sources were checked against GitHub's documentation.
A hands-on hardening guide for teams that already ship on GitHub Actions. 14 November 2026, Asia/Dhaka. By the Rokkhe research team.
A tag like @v6 is convenient, and it is also movable. Anyone who can push a new commit to that tag can change the code your workflow runs the next time the tag is resolved. The same workflow often also hands the job a GITHUB_TOKEN that can write to the repository. Those two defaults — a movable pointer and a write-capable token — are what this post closes.
You will leave with three things: every uses: line pinned to a full 40-character commit SHA, a workflow that starts from read-only permissions, and a short list of pull_request_target mistakes to avoid. All of the commands below are read-first. None of them changes a GitHub setting until you choose to apply the YAML.
This is the first post in a ten-part how-to series. It is the only CI/CD-focused guide in the set. Later posts cover the OWASP Top 10, secrets, CVE triage, cloud and Kubernetes hardening, API and LLM-app security, an AI review harness, and Sigma rules.
TL;DR
- Pin third-party Actions, and your own reusable workflows, to a full commit SHA, not a tag or branch. GitHub’s own documentation tells you to do this.
- Put a
permissions:block at the top of every workflow so the defaultGITHUB_TOKENis read-only, then grant extra scopes on the job that needs them. - Treat
pull_request_targetas a privileged event: it runs in the base repository’s context and can see secrets. Do not check out and run untrusted pull-request code in that workflow. - Audit what you already have before you change production deploy workflows. The commands below only read.
Why a moving tag is not a pin
An Action is just a Git repository. When a workflow says:
- uses: actions/checkout@v6
GitHub resolves v6 to whatever commit that tag currently points at. Tags are not immutable. A tag can be deleted and recreated, or force-moved, so the same @v6 on Monday is not guaranteed to be the same code on Tuesday.
A full Git commit SHA names one object. This is the form GitHub documents for pinning:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
The SHA above is an example of the form: it is what the v6.1.0 tag of actions/checkout resolved to when we checked on 14 November 2026. Before you paste any SHA into a production workflow, resolve it yourself from the Action’s official repository, as shown in the next section. Do not copy SHAs from a blog post and assume they are still the latest release.
Pinning does not mean “never update.” It means updates become a deliberate pull request: a new SHA, a comment with the version, and a review. That is the point. You want the change to be visible.
Pin every uses: line to a full commit SHA
Work in a branch. For each Action, resolve the tag you intended to use to a 40-character SHA from the official repository, not a fork.
# Example only: resolve a tag on the official actions/checkout repo.
# Replace the URL and tag with the Action you actually use.
git ls-remote https://github.com/actions/checkout.git refs/tags/v6.1.0
You want the peel to a commit, not an annotated-tag object. If the command prints both a tag object and a peeled commit (^{}), take the commit:
git ls-remote --tags https://github.com/actions/checkout.git 'v6.1.0*'
Confirm the commit is on the official repo in the browser: https://github.com/OWNER/ACTION/commit/THE_SHA. Then write the pin with the human-readable version in a comment, which GitHub’s documentation encourages so reviewers can see what you meant:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Apply the same rule to reusable workflows:
jobs:
call-shared:
uses: octo-org/common/.github/workflows/build.yml@FULL_40_CHAR_SHA
A branch name (@main) or a moving major tag (@v2) on a uses: line is not a pin. Abbreviated SHAs are also the wrong form; GitHub asks for the full length.
If you use Dependabot or a similar updater, configure it to open pull requests that bump the SHA and the comment together. The pin is only useful if the update path is boring.
Set default workflow permissions to read-only, then grant per job
The GITHUB_TOKEN is created automatically for each job. GitHub’s guidance is to grant it the minimum permissions the job needs, and to set the default for repository contents to read-only. The clean way to do that in the workflow file is an explicit top-level block:
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- run: make test
When a job must write, raise the scope on that job only:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
# publish steps go here
id-token: write is not a repository-write grant. It only lets the job request an OIDC token for a cloud exchange. That is expected if you later move off long-lived cloud keys, but it should still be limited to the job that talks to the cloud.
Also set the repository (or organization) default in the GitHub UI so a workflow file that forgets permissions: does not silently get a write token: Settings → Actions → General → Workflow permissions → Read repository contents and packages permissions. The YAML block is still required, because it is reviewed with the code.
Do not use permissions: write-all. If you see it in a copied example, delete it.
Treat pull_request_target as a privileged event
pull_request runs in the context of the merge commit and, for a fork, with a restricted token and no secrets. pull_request_target is different. GitHub’s documentation is explicit: the workflow runs in the base repository’s context, with the base repository’s GITHUB_TOKEN and access to secrets. That is what makes it useful for labelling or commenting on pull requests from forks. It is also what makes it dangerous.
The safe pattern is: the workflow file, and any actions/checkout that does not pass an explicit ref, come from the default branch of the base repository. Untrusted code from the pull request is not executed.
The unsafe pattern, which GitHub and the GitHub Security Lab have both warned about, is to check out the pull request’s untrusted ref and then run its install or test scripts in that same privileged job:
# Unsafe pattern — do not use this.
# It is shown only so you can recognise it in a review.
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm test # this runs untrusted code with base secrets
If you need to comment on a fork pull request, keep the privileged job small: no checkout of the head SHA, no npm install of the head branch, no running of the contributor’s Makefile. Use a pull_request workflow for tests, and a tightly scoped pull_request_target workflow only for authentication-needed comments, with permissions limited to pull-requests: write.
Two newer guardrails are worth knowing. GitHub now applies a default Actions event policy that blocks pull_request_target in public repositories that have no event policy of their own. It currently runs in evaluate mode, and GitHub says it will enforce it on 2 December 2026, so check your policy insights now if you rely on this event. Recent versions of actions/checkout also refuse to check out a fork's pull-request head in this context unless you set allow-unsafe-pr-checkout: true. Treat that flag as a review red flag: only set it when the checked-out code is read as data and never executed.
GitHub also documents cache behaviour for this event: pull_request_target workflows get read-only access to the default branch’s cache, unless you opt into a writeable cache mode, which reintroduces cache-poisoning risk. Leave the default alone.
A copy-paste starter workflow
Use this as a starting point for a private or public repository that runs tests on pull requests. Resolve the checkout SHA yourself before you merge it.
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Run tests
run: |
set -euo pipefail
if [ -f Makefile ]; then
make test
elif [ -f package.json ]; then
npm ci
npm test
else
echo "Add the test command for this repository."
exit 1
fi
There is no pull_request_target here, no write-all, and no secrets. Add deploy jobs in a separate workflow, on a trusted event such as push to main or a release, with job-level permissions and an environment that requires a reviewer if the job can touch production.
How to audit the workflows you already have
These commands only read the files on disk. Run them in a clone of the repository.
List every workflow and every uses: line that is not pinned to a 40-character SHA:
# From the repository root.
find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) -print
# uses: lines that do not end in a 40-character hex SHA (tags, branches, short SHAs).
grep -nE 'uses:' .github/workflows/*.{yml,yaml} 2>/dev/null \
| grep -vE 'uses:[[:space:]]*[^@]+@[0-9a-f]{40}([[:space:]]|#|$)'
List workflows that never set permissions::
for f in .github/workflows/*.{yml,yaml}; do
[ -f "$f" ] || continue
if ! grep -qE '^[[:space:]]*permissions:' "$f"; then
printf 'No permissions block: %s\n' "$f"
fi
done
List pull_request_target workflows so a person can review them:
grep -n 'pull_request_target' .github/workflows/*.{yml,yaml} 2>/dev/null
On GitHub, also open Actions → workflow run logs after a test change and confirm secrets are redacted, as GitHub recommends in its secure-use reference. If a secret ever appears in a log, delete that log and rotate the secret. Finding a leak is the subject of post 3 in this series.
Checklist
- [ ] Every
uses:line, including reusable workflows, pins a full 40-character SHA from the official repository. - [ ] A version comment sits next to each SHA so reviewers can see the intended release.
- [ ] Each workflow file has a top-level
permissions:block; the default iscontents: read. - [ ] Write scopes are granted only on the job that needs them, never with
write-all. - [ ] The repository (or organization) default workflow permission is also read-only.
- [ ]
pull_request_targetis used only when a base-repository token is required, and that job does not check out and execute untrusted head code. - [ ] Deploy jobs use a GitHub Environment with required reviewers when they can reach production.
- [ ] An updater (Dependabot or equivalent) can open PRs that bump the SHA and the comment together.
- [ ] Someone has grepped the existing workflows with the audit commands above.
- [ ] If a secret was ever printed in a log, that log was deleted and the secret was rotated.
FAQ
Is pinning to @v6.1.0 (an exact tag) good enough?
No. An exact tag is more stable than @v6, but the tag can still be moved. GitHub’s documented pin is the full commit SHA, with the tag in a comment.
Does a SHA pin mean we are stuck on an old Action?
No. You update by changing the SHA in a pull request, the same way you update any other dependency. The difference is that the update is reviewed, not silent.
Can we pin to a short SHA?
Do not. Use the full 40-character object name.
We need to comment on pull requests from forks. Must we use pull_request_target?
Often yes, because a privileged token is required to comment. Keep that workflow small, grant only pull-requests: write, and do not run the contributor’s build in that job.
Will this break Dependabot?
Only if Dependabot is not configured to update Actions. Once it is, it should open PRs that retarget the SHA. Review those PRs the way you review application bumps.
Sources
- GitHub Docs, Using pre-written building blocks in your workflow
- GitHub Docs, Secure use reference
- GitHub Docs, Securely using pull_request_target
- GitHub Docs, Use GITHUB_TOKEN for authentication in workflows
Next in the series
Next up: Test and fix the OWASP Top 10 2025 in your web app this week.
Rokkhe is an AI pentesting and security-operations workspace. Learn more at rokkhe.com.
Top comments (0)