# Pin your GitHub Actions by commit SHA and lock workflow permissions

*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 default `GITHUB_TOKEN` is read-only, then grant extra scopes on the job that needs them.
- Treat `pull_request_target` as 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:

```yaml
- 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:

```yaml
- 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.

```bash
# 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:

```bash
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:

```yaml
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:

```yaml
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:

```yaml
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**:

```yaml
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:

```yaml
# 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.

```yaml
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:

```bash
# 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:`:

```bash
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:

```bash
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 is `contents: 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_target` is 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](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions)
- GitHub Docs, [Secure use reference](https://docs.github.com/en/actions/reference/security/secure-use)
- GitHub Docs, [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)
- GitHub Docs, [Use GITHUB_TOKEN for authentication in workflows](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token)

## 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](https://rokkhe.com/).
