Using Third-Party Actions Safely

Almost every GitHub Actions workflow pulls in code you did not write. A single line like uses: some-vendor/deploy-action@v3 downloads someone else’s repository and runs it inside your CI environment, usually with access to your repository contents, your job’s GITHUB_TOKEN, and whatever secrets you have exposed to that step. That convenience is also the single biggest supply-chain risk in modern CI/CD. This lesson covers how to evaluate, pin, and scope third-party Actions so a compromised or malicious Action cannot quietly exfiltrate your secrets, tamper with your build artifacts, or push unauthorized commits.

Overview / How it works

An Action is packaged as its own Git repository (JavaScript, Docker container, or composite) and referenced with owner/repo@ref. The ref can be a branch name, a tag, or a full commit SHA. Branches and tags are mutable pointers: the maintainer of that Action (or an attacker who steals the maintainer’s credentials or npm/publish token) can repoint v3 or main at a completely different commit at any time, and your workflow will silently start running the new code on its next run. A commit SHA, by contrast, is immutable — it always resolves to the exact bytes that existed when you pinned it.

Once an Action’s code starts executing inside a job, it runs with the same trust boundary as your own build steps. It can read environment variables, read any secret passed to it through with: or env:, write to the filesystem, and make outbound network calls unless you have restricted egress. There have been real incidents where a popular, widely trusted Action was compromised upstream and modified to dump CI secrets into build logs for every repository that used it on the next run — without any change to the version tag in the victim’s workflow file. Pinning and permission scoping exist specifically to contain that blast radius.

Syntax or workflow structure

A hardened uses: line has three parts: the exact commit SHA, a trailing comment recording the human-readable version for maintainability, and a workflow- or job-level permissions: block that grants only what that job actually needs.

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: owner/repo@<full-40-character-commit-sha> # v3.1.0

GitHub also lets an organization owner restrict which Actions can be used at all, from “any Action, including from outside the org” down to “only Actions created by GitHub, plus an explicit allow-list.” That org-level control is your last line of defense if an individual workflow author forgets to pin or scope correctly.

Examples

Example 1: the naive, unpinned version. This is what most tutorials show, and it is what most teams ship without thinking twice.

name: Deploy Site
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy with third-party action
        uses: some-vendor/deploy-action@v3
        with:
          api-token: ${{ secrets.DEPLOY_TOKEN }}
          host: ${{ secrets.DEPLOY_HOST }}

Expected behavior: on every push to main, GitHub resolves @v4 and @v3 to whatever commit those tags currently point to, checks it out, and runs it with your deploy secrets available as environment input. If some-vendor‘s maintainer account is compromised and the tag is repointed, your next deploy runs the attacker’s code with your DEPLOY_TOKEN and DEPLOY_HOST in scope, and nothing in your diff or workflow history shows a change.

Example 2: pinned and scoped. Same job, hardened.

name: Deploy Site
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Deploy with third-party action
        uses: some-vendor/deploy-action@8f2e4c9b6a1d3e5f7a9c0b2d4e6f8a0c2e4f6a8b # v3.1.0
        with:
          api-token: ${{ secrets.DEPLOY_TOKEN }}
          host: ${{ secrets.DEPLOY_HOST }}

Expected behavior: functionally identical output, but the exact code that runs is fixed. If the vendor’s tag is later repointed to malicious code, your workflow still checks out the original, audited commit. Upgrading is now a deliberate act — you change the SHA and comment together, ideally after reading the diff between the old and new commit. The example commit SHAs above are illustrative; always copy the real SHA from the Action’s release page or resolve it yourself before using it.

Example 3: the pull_request_target trap. A team wants to build a preview of every pull request, including ones from forks.

name: PR Preview (INSECURE)
on:
  pull_request_target:
    branches: [main]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Install and build
        run: |
          npm ci
          npm run build
      - name: Publish preview
        uses: some-vendor/preview-action@8f2e4c9b6a1d3e5f7a9c0b2d4e6f8a0c2e4f6a8b # v2.0.0
        with:
          token: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}

Expected (dangerous) behavior: pull_request_target runs in the context of the base repository, so it has access to repository secrets even for pull requests opened from forks by strangers. This workflow then checks out the fork’s untrusted head commit and runs its npm ci / npm run build — arbitrary attacker-controlled code, including anything in package.json install scripts — with PREVIEW_DEPLOY_TOKEN sitting in the job’s environment. A malicious pull request can read that secret and exfiltrate it in seconds.

Step by step

  1. Evaluate before adding an Action. Check who publishes it, how many repositories depend on it, whether its source is readable, and whether it has a security policy. Prefer Actions published by GitHub itself or by the tool’s own maintainers over unfamiliar third parties.
  2. Read the code you’re about to trust, at least the entry point and anything that touches with: inputs or environment variables, before wiring in secrets.
  3. Pin to a full commit SHA, not a tag or branch, and add a comment with the human-readable version so future readers know what shipped.
  4. Set explicit permissions: at the workflow level (deny-by-default) and again at the job level for anything that needs more, using the narrowest scope the job requires.
  5. Decide whether the job needs secrets at all. If it only builds or tests untrusted code (like a fork’s pull request), keep it on the default, unprivileged GITHUB_TOKEN and no repository secrets.
  6. Gate anything privileged behind a protected environment with required reviewers, so a deploy or publish step needing secrets only runs after a human approves it.
  7. Enable Dependabot (or an equivalent) for Actions so pinned SHAs get proposed updates as pull requests, which you review like any other dependency bump rather than silently trusting a moving tag.

Common Mistakes

Mistake 1: pinning to a major-version tag and calling it done.

- uses: some-vendor/deploy-action@v3

Tags such as v3 or v3.1 are mutable references the publisher can repoint at will. Even well-intentioned publishers sometimes retag a release after finding a bug, and a compromised account can retag maliciously. Correction: resolve v3 to its commit SHA once and reference that, keeping the version as a trailing comment:

- uses: some-vendor/deploy-action@8f2e4c9b6a1d3e5f7a9c0b2d4e6f8a0c2e4f6a8b # v3.1.0

Mistake 2: leaving default permissions in place for a job that touches secrets. On repositories where the default GITHUB_TOKEN permissions are broad, every step in every job — including third-party Actions — inherits write access to contents, issues, pull requests, and more, whether or not it needs any of it. A compromised Action can use that token to push commits, open releases, or tamper with PRs. Correction: set permissions: contents: read at the top of the workflow and add back only the specific scope, like contents: write or packages: write, on the one job that genuinely needs it.

Mistake 3: using pull_request_target to build and run fork code. As shown in Example 3, checking out github.event.pull_request.head.sha under pull_request_target exposes base-repository secrets to attacker-controlled code. Correction: build and test untrusted fork code under the regular pull_request trigger, which runs with a read-only token and no repository secrets. Only use pull_request_target for trusted, narrow tasks — like labeling or commenting — that never check out or execute the fork’s code, and if you must publish a preview, do it as a separate job gated by if: github.event.pull_request.head.repo.full_name == github.repository and a protected environment, as shown below.

name: PR Preview (safer)
on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Install and build
        run: |
          npm ci
          npm run build

  publish-preview:
    needs: build
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    environment: pr-previews
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Publish preview
        uses: some-vendor/preview-action@8f2e4c9b6a1d3e5f7a9c0b2d4e6f8a0c2e4f6a8b # v2.0.0
        with:
          token: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}

Here the build job runs on ordinary pull_request semantics with no secrets exposed, so fork code can safely run its install and build scripts. The publish-preview job only runs for pull requests from the same repository (never forks) and sits behind an environment that can require manual approval, so PREVIEW_DEPLOY_TOKEN is only reachable through a reviewed, non-fork path.

Best Practices

  • Pin every third-party Action to a full commit SHA with a version comment; never trust a floating tag or branch for anything that touches secrets or write access.
  • Start every workflow with permissions: contents: read at the top level, then grant additional scopes only on the specific job that needs them.
  • Prefer Actions published by GitHub (actions/*) or by the tool’s own vendor over unfamiliar third parties, and read the source before adding a new one.
  • Use an organization-level allow-list for Actions where policy allows it, so individual workflow authors cannot introduce an unvetted Action even by accident.
  • Never run pull_request_target against code checked out from a fork’s head ref; keep untrusted builds on pull_request with the default read-only token.
  • Gate any step that needs a deployment or publishing secret behind a protected environment with required reviewers, so a compromised or malicious step cannot reach production credentials unattended.
  • Keep pinned SHAs current with a scheduled dependency-update tool so upgrades arrive as reviewable pull requests instead of being ignored indefinitely.
  • Treat self-hosted runners as especially high-risk for public repositories: a fork’s workflow can execute arbitrary code on infrastructure you control, so restrict self-hosted runners to trusted, internal workflows only.
  • Remember these examples are templates — verify the real commit SHA for any Action you adopt, and adapt hosts, tokens, and environment names to your own infrastructure securely.

Practice Exercises

  1. Take a workflow in your own project that references an Action with @v1, @v2, or similar. Look up the Action’s release page, find the commit SHA behind that tag, and rewrite the uses: line to pin it with a trailing version comment.
  2. Write a workflow permissions block for a job that only needs to read repository contents and post a comment on the triggering pull request. Decide exactly which two scopes belong in permissions: and justify why nothing broader is needed.
  3. Review Example 3’s insecure preview workflow and explain, in your own words, what an attacker-controlled package.json install script could do with access to secrets.PREVIEW_DEPLOY_TOKEN. Then sketch how you would restructure the workflow so untrusted code and the secret are never present in the same job.

Summary

Third-party Actions are dependencies that execute with real access to your repository and secrets, so they deserve the same scrutiny as any other supply-chain dependency. Pin every Action to an immutable commit SHA instead of a mutable tag, default every workflow to minimal permissions: and widen only where necessary, and never let untrusted fork code run in a context — like pull_request_target — that carries your repository’s secrets. Applied consistently, these controls turn a single compromised or malicious Action from a full breach into a contained, low-impact event.