GitHub·8 min read·

Action Security: Pinning, Permissions and Third-Party Risk

Reduce supply-chain risk in GitHub Actions by pinning third-party actions to a commit SHA, granting least-privilege permissions, and guarding your secrets.

NB

Netbay Developer Relations

Netbay Engineering

On this page

Your workflow is a small supply chain: it runs third-party actions, reads secrets, and can push to registries. Every one of those is an attack surface. The good news is that GitHub Actions has strong, quiet defaults, and the hardening moves are cheap. This post covers pinning actions, least-privilege permissions, and guarding secrets — the three controls that matter most.

Pin actions to a commit SHA

Third-party actions execute arbitrary code with your token and secrets. The first line of defense is pinning to a full commit SHA instead of a floating tag. A tag like v3 can be moved by its maintainer, or an upstream repo could be renamed or compromised, silently changing what runs. A full 40-character SHA is immutable, so you run exactly the code you reviewed.

The trade is convenience: tags are readable and easy to upgrade; SHAs are opaque. The common compromise is to reference the SHA in the workflow and pin the version to a tag in a comment so you know what it is. Upgrade deliberately by moving to a new reviewed SHA.

yaml
- uses: docker/setup-buildx-action@0b937e0cbb4d50b31f16b2d82f96c2c0a6a5b1f2  # v3
- uses: actions/checkout@85c0c2f9b9e5b0b8c7f5a2a4e5d5c8f0b6a1d3e9  # v4

Reviewing third-party actions before you adopt them is part of the same discipline — a popular action can still do something you do not want. Prefer actions from the actions org and well-known maintainers, and pin everything.

Grant least privilege with permissions

Every run comes with a token (GITHUB_TOKEN) and, by default in recent GitHub, a restricted permission set for new repos. You should state your needs explicitly. At the workflow level, set the permissions block to deny by default and grant only what jobs need. This confines what that token can do if a step is compromised.

yaml
permissions:
  contents: read
  issues: read
  # no write access granted at all

For jobs that genuinely need to push, scope the write to the narrowest right — for example, packages: write plus contents: read for a container build job, and nothing more. Resist granting everything. The default-max permissions (permissions: {} means empty/none) is the safest baseline, and you add the smallest set that still lets the workflow do its job.

Guard your secrets hard

Secrets are the crown jewels. A few rules go a long way. Store secrets at the environment or repository level, never inline them. Reference them via the secrets context so GitHub masks them in logs. Scope secrets to the narrowest environment that uses them — a production key should not be visible to a pull-request-triggered job that a stranger can touch. And treat secrets referenced in run lines with extra care, because a small mistake can echo their value into logs before masking.

Also be wary of pull_request_target. Unlike pull_request, pull_request_target runs your workflow in the context of the base repository, which gives it access to secrets. That is useful for labeling PRs but dangerous if you ever check out and execute untrusted PR code. If you use pull_request_target, never checkout the PR code or run untrusted scripts with the privileged token; gate any sensitive step and review what can execute.

Validate untrusted inputs

Anything from a pull request can be untrusted: branch names, titles, and file paths. If you interpolate them into a run line, a crafted value can inject commands. Always quote interpolations, treat inputs as data, and validate before use. Using expressions in if conditions is safe (they do not reach a shell), but placing untrusted values directly inside a run script is where injection happens.

A hardened example

Combining the controls produces a workflow that builds a container and uploads an artifact with the minimum of power:

yaml
name: Hardened Build
on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@a5eff6f7c1b0a3c4d5e6f7a8b9c0d1e2f3a4b5c6  # v4
      - uses: docker/setup-buildx-action@0b937e0cbb4d50b31f16b2d82f96c2c0a6a5b1f2  # v3
      - uses: docker/build-push-action@a6c6a2a2c8d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4  # v6
        with:
          push: true
          tags: ghcr.io/octo-org/app:latest

Note the token gets only the write it needs for the package, plus read for the repo, plus id-token for any OIDC exchange. Nothing more.

pin to SHA least privilege secret hygiene immutable code, reviewed permissions: contents:read only beware pull_request_target + untrusted inputs every control narrows an attack surface

Takeaway

Pin every third-party action to a SHA, grant the token the narrowest set of permissions it truly needs, and keep secrets scoped and masked. Together these make a workflow far harder to abuse through the supply chain. Once your hardened pipeline is ready, run it against infrastructure you trust — provision an Ubuntu VPS on Netbay in under a minute at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance