Home » GitHub Actions Permissions: Reducing CI Supply-Chain Exposure

GitHub Actions Permissions: Reducing CI Supply-Chain Exposure

2 comments
A miniature railway splits into three branches, with a green route to an open gateway and red barriers on two others.

Review a GitHub Actions workflow as a program that receives both untrusted input and credentials. Start by identifying the triggering event, the code it executes, and the authority available to each job. Then reduce token permissions, separate privileged work, handle event text as data, and review the actions and artifacts that cross those boundaries.

A workflow can use a read-only repository token and still expose a cloud credential supplied separately. It can also avoid secrets and still execute untrusted code on a valuable self-hosted runner. The permission review must cover the job's effective environment, not just one YAML field.

This guide uses a synthetic workflow and local checks. No workflow was installed in a repository, and no GitHub-hosted runner or live credential was exercised.

Trace authority from the event to the last command

Make a table for the workflow you are reviewing:

Question Evidence to inspect
Who can trigger the run? Event, branch filters, fork behavior, repository settings
Whose code runs? Workflow revision, checkout ref, scripts, dependencies, downloaded artifacts
What can it access? Token permissions, explicit secrets, environment credentials, runner identity
What leaves the job? Logs, artifacts, caches, releases, package uploads, external requests

For example, a pull-request title check needs the title. It does not need to check out source, run a dependency installer, access a package registry, or write repository contents. Removing those unnecessary capabilities makes the intended contract easier to review.

Conversely, a release job may legitimately need package-write access. That authority belongs in the narrowly scoped release job, with a trusted source revision and the appropriate environment controls. Sharing it with every test job enlarges the consequences of a compromised test dependency.

Declare permissions where the job needs them

The GitHub workflow syntax reference supports permissions at workflow and job scope. When permissions are specified, unspecified token permissions are set to none. Begin with an explicit minimal baseline and add the required permissions to each job; verify the resulting behavior against repository and organization policy.

Here is a deliberately small title-validation workflow:

name: validate-title
on: [pull_request]
permissions: {}
jobs:
  title:
    runs-on: ubuntu-latest
    timeout-minutes: 2
    steps:
      - name: Require a nonempty title
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        shell: bash
        run: |
          test -n "$PR_TITLE"
          printf '%s\n' "$PR_TITLE"

The job does not call the GitHub API or check out a repository, so it does not need token permissions for those operations. A source-test job commonly needs repository contents access for checkout; an API-based labeling job may need a different write permission. Derive the grant from the actual call rather than copying write-all from a convenient example.

We parsed this YAML locally, checked its empty permission map and trigger, and ran its shell body with a synthetic title containing quotes and the literal text $(touch marker). The output retained the title as data, and no marker file was created. This verifies the demonstrated shell handling. It does not verify GitHub's token enforcement or event-policy configuration.

Keep event text out of generated shell source

The dangerous alternative is direct expression interpolation into a run script. An attacker-controlled title can alter the shell program before the runner executes it. GitHub's script-injection documentation describes this risk for issue text, pull-request fields, branch names, and other event context.

Passing the title through env and using a quoted shell variable avoids that particular source-generation problem. It does not sanitize the title for every future use. Feeding the variable into eval, a command interpreter, an unsafe query builder, or another executable format would introduce a new boundary. Review the destination as well as the first assignment.

Even ordinary output may need limits. If the input is confidential or attacker-controlled multiline data, decide whether it belongs in logs at all. A title check can usually log its verdict without reproducing the entire title.

Do not bring untrusted code into a privileged job

pull_request and pull_request_target have different trust contexts. GitHub's current guide to pull_request_target explains that the latter uses the base repository's trusted workflow context. Fetching a contributor's code and executing its tests or build scripts can then expose that privileged context to untrusted code.

Avoid that combination. A metadata-only operation should remain metadata-only. If you separate untrusted validation from a privileged follow-up, treat the first workflow's artifacts as untrusted input. A second workflow does not make an uploaded script or claimed test result trustworthy merely by downloading it.

GitHub's protections and event policies are evolving. The documentation checked on October 28, 2026 describes a default public-repository policy with evaluate-mode and enforcement timing distinctions. Do not assume a run is blocked just because a policy exists. Inspect the repository's actual policy and effective mode before claiming protection.

Review dependencies and the runner too

Pin external actions to reviewed full commit SHAs and record the corresponding release for humans. GitHub's secure-use reference explains why immutable references help prevent a mutable tag from selecting different action code. A pin establishes identity; it does not prove the selected code is benign, maintained, or compatible with your runner.

Review the action's own downloads, scripts, and required permissions. A pinned action that downloads an unpinned executable still has a moving dependency. Use an update process that proposes new pins, explains relevant upstream changes, and runs the workflow's meaningful checks.

For self-hosted runners, inspect persistence, network access, mounted credentials, and other jobs sharing the machine. Reducing GITHUB_TOKEN permissions does not revoke access already available through the host. Prefer an isolated, disposable environment for untrusted work, and verify what survives after the job.

Finish the review with evidence for each boundary: effective token grants, a trusted source revision for privileged work, safe input handling, reviewed dependencies, and an appropriate runner. Local YAML validation can support that review, but a green syntax check cannot substitute for examining what the job can actually do.

You may also like