When you push a commit, open a pull request, or finish a review, GitHub can respond by running a workflow: a YAML file that tells GitHub Actions to execute commands on a runner, the machine that performs the job. For each workflow run, GitHub creates a credential called the GITHUB_TOKEN, an access token that it revokes when the run finishes. The token’s permissions decide what the workflow can do to the repository, from reading code to pushing commits, creating releases, or approving pull requests.
Most workflows do not need most of those abilities, but they run with them by default. GitHub grants read-only token permissions to organizations created on or after February 2, 2023, and read/write permissions to older accounts. As a result, many existing repositories give every job the power to modify the repository. This becomes dangerous when a workflow runs code you do not control, such as a malicious pull request or a compromised third-party action: the attacker inherits every permission the token holds. Restricting GitHub Actions permissions means giving each job the narrowest token scope it needs, keeping secrets out of jobs that do not require them, and controlling which external actions the repository may run. You will see how to scope the token per job, where secrets belong, how to evaluate third-party actions, and finally a checklist for reviewing your own workflows.
Scope the GITHUB_TOKEN to each job
GitHub sets the token’s default permissions at the enterprise, organization, and repository level, and the default depends on when the account was created. For organizations and enterprises created on or after February 2, 2023, GitHub defaults the GITHUB_TOKEN to read-only; older accounts default to read/write, so every job can push commits, create releases, and modify issues. You can set the enterprise default to restricted so individual organizations cannot choose a more permissive default, then apply the same restriction at the organization and repository levels.
Workflow steps use the token to authenticate to the GitHub API. When a job pushes a tag, runs gh release create, or comments on a pull request, it uses this token. The token’s permissions are the ceiling on what those commands can accomplish.
The most important setting is in the workflow file. Define permissions per job with jobs.<job_id>.permissions instead of at the workflow level, because a workflow-level permissions block grants the same permissions to every job in the file. GitHub’s recommendations for actions security make the same point: a job that only runs tests should declare contents: read and nothing else, while a release job that pushes a tag and creates a GitHub Release is the only job that needs contents: write. Start from permissions: {}, an empty set, and add back only the permissions each job uses.
GitHub also offers a setting that lets the token create and approve pull requests. Turn off "Allow GitHub Actions to create and approve pull requests" at the organization and repository level so a workflow cannot approve its own pull request or open new ones. Automated approval should never replace required human review, because a compromised workflow would simply approve the change that compromised it.
Store secrets at the level that matches their use
Secrets live in three places: the organization, the repository, and repository environments. Choose the level by scope. A Slack bot token that several repositories use belongs at the organization level. At the repository level, store a unique token for a single container registry. Keep a publishing profile that only the production deployment job needs in the production environment. GitHub’s least-privilege guidance for secrets recommends storing the secret at the narrowest level that still covers every workflow that needs it.
Repository environments give you a second control. An environment is a named target such as production or staging, and you can attach protection rules to it. You can, for example, allow only the main branch to deploy to production and require manual approval before the deployment job runs. Environment protection rules keep environment secrets visible only to jobs that reference that environment, so a pull-request build that runs tests never sees the production credentials.
The most common secret mistake is writing the value directly into the workflow file. A value in YAML ends up in repository history, forks, logs, and backups. GitHub’s secret masking cannot help because masking only applies to values referenced through the secrets context. Store values in GitHub’s encrypted store and reference them as ${{ secrets.NAME }}. Long-lived cloud credentials are the second common mistake: an AWS or Azure access key stored as a secret stays valid for months or years if it leaks, giving whoever finds it continued access to your infrastructure.
OpenID Connect removes the long-lived credential entirely. With OIDC, the workflow exchanges a short-lived token directly with the cloud provider during the run, so the provider issues a temporary credential that expires when the job ends. Configure the provider’s trust relationship with granular claims, such as the repository, workflow, and environment. Then only a specific workflow in a specific repository can assume the cloud role.
Vet and pin third-party actions
Every third-party action you reference runs inside your workflow with the same token and secrets as your own steps. The marketplace does not guarantee safety. Legit Security analyzed 2.5 million workflow files from 553,000 organizations and personal users and found 19,113 custom marketplace Actions. Only 913 of those were created by verified GitHub users. The average OpenSSF Scorecard score, a supply-chain hygiene measure, was 4.23 out of 10.
Maintenance is a red flag on its own. Most custom Actions are maintained by a single developer, many by developers with less than one year of experience. Also, 762 marketplace Actions are archived, meaning they receive no updates or bug fixes. A popular action with one maintainer is a single point of failure, and an archived action can carry a known vulnerability indefinitely.
That risk materialized in March 2025. Attackers compromised tj-actions/changed-files, a widely used action for detecting changed files, and injected malicious code that dumped runner memory and exposed secrets in workflow logs (CVE-2025-30066). This affected more than 23,000 repositories. The attack began with a compromise of reviewdog/action-setup@v1. The attackers stole a bot’s personal access token and moved tags to point at malicious commits. A follow-up incident in April 2025 saw a new maintainer briefly publish version v44 of the same action with obfuscated shell commands capable of remote code execution. This affected an estimated 20,000 repositories.
Pin every third-party action to a full-length commit SHA instead of a tag or branch name. A tag is a label the owner can move, which is exactly how the tj-actions attack spread. A commit SHA identifies one immutable commit. GitHub’s security guidance recommends this. Use the SHA from a tagged release and add a comment with the version number so Dependabot can still propose updates when a new version ships. Note the tradeoff: Dependabot sends security alerts for actions referenced with semantic versioning, while SHA-pinned actions receive version updates but not security alerts. The comment is what lets you track which version you are on.
A commit SHA does not make the action itself immutable. Avoid actions with mutable dependencies, such as unpinned container images, composite actions that reference moving tags, or scripts that download code from the internet at run time without verification. Workflow lockfiles, generally available since October 2026, pin transitive action dependencies to exact commits. They do not cover reusable workflows called at the job level, so pin those by SHA as well.
Finally, restrict which actions can run. The enterprise and organization setting "Allow enterprise, and select non-enterprise, actions and reusable workflows" limits execution to GitHub-authored actions plus a small set you have individually approved. In CI, Ensure SHA Pinned Actions can fail the build when a workflow references an unpinned action.
Treat pull_request_target and untrusted input as dangerous
The most dangerous event trigger is pull_request_target. A normal pull_request workflow runs with the code from the pull request but without repository secrets. A pull_request_target workflow runs in the base repository context, which means it has access to repository secrets and the GITHUB_TOKEN. If such a workflow checks out and executes the pull request’s code, the PR author controls code that runs with your secrets and write permissions. Security researchers call this pattern the "pwn request". Write-ups on common GitHub Actions mistakes are blunt: never check out and run pull request code in a workflow that has secrets or write access. GitHub is also tightening the default here. On public repositories, pull_request_target without an applicable event policy currently runs in evaluate mode and will be blocked starting December 2, 2026.
Workflow injection does not require a special trigger. Any time a workflow inserts untrusted input into a run: shell block, such as a pull request title, branch name, or issue body, the author of that input can break out of the intended command and execute anything. Pass untrusted values through environment variables instead of concatenating them into shell commands. An environment variable carries the value without letting it become part of the command line.
Self-hosted runners deserve their own warning. A self-hosted runner is a machine you own that GitHub sends jobs to. If a public repository uses one, anyone who opens a pull request can cause the runner to execute code on that machine. Do not attach self-hosted runners to public repositories. When you use them for private work, use ephemeral runners that are destroyed after each job, keep runners for different trust levels separate, patch the hosts, and restrict their network access.
A checklist for reviewing workflows
One structural control belongs in every review: workflow files are executable code, and anyone who can edit .github/workflows can run commands on your runner with your secrets. Configure CODEOWNERS so changes to the workflows directory require review by a designated team, and add branch protection that requires that review before merging into main and blocks force pushes.
Answer these questions for each workflow. A "no" answer is a reason to change the workflow or the repository settings before the next run.
- Are permissions defined explicitly at the job level, starting from
permissions: {}and granting only what the job uses, rather than relying on the repository default or a workflow-level block? - Does each job read only the secrets it needs, and is each secret stored at the narrowest level that works: environment, then repository, then organization?
- Is every third-party action pinned to a full-length commit SHA from a tagged release, with a comment noting the version, and are actions with mutable dependencies absent?
- Are cloud credentials short-lived, ideally through OIDC, instead of long-lived access keys sitting in secrets?
- Can workflow files change only through review, with CODEOWNERS owning
.github/workflowsand branch protection requiring that review? - Do any jobs run on self-hosted runners, and if so, are those runners isolated from public repositories and untrusted workloads?
- Do logs stay free of secret values, and are secrets referenced only through the
secretscontext so GitHub’s masking applies? - Is untrusted input, such as pull request metadata or artifacts from other workflows, kept out of shell commands, and is
pull_request_targetabsent or used only to run trusted base-repository code?