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.