CI/CD security

GitHub Actions permissions: least privilege by job

Set GitHub Actions permissions at the job that needs them. Give test jobs only the repository access required to run, and grant release permissions only to a release job on a trusted trigger. A job-level permissions map replaces the workflow-level map for that job; any permission omitted from the job map becomes none. Review the token, event, secrets, and cloud trust policy separately before calling the release path safe.

Why one release job should not privilege every test

A workflow often combines jobs with very different purposes. A test job checks out code and runs assertions. A release job may create a GitHub deployment record and request a cloud identity token. Giving both jobs workflow-wide write access expands what every step and third-party action in the test path could do if it is compromised or handles untrusted input. The smaller design is to state the permissions each job actually uses. Keep the test job read-only where its dependencies allow it, and reserve write scopes for the release job.

GitHub calculates GITHUB_TOKEN permissions from the repository or organization default, then applies workflow-level declarations, then job-level declarations, followed by event-specific restrictions. A permissions map is a complete statement for that level: once any permission is specified, all unlisted permissions are set to none. A job's map therefore does not add a single grant to a workflow map. It overrides the workflow-level permissions for that job. This distinction matters when a release job names deployments: write but forgets contents: read: checkout may no longer have the read scope it needs.

Start from the operation, not from an expansive preset. List the GitHub API calls and repository reads each job performs. If the test job only checks out code from the same repository, contents: read may be enough for the GITHUB_TOKEN. If it posts a comment or uploads an artifact through a particular API, check the exact permission that operation requires before granting it. Do not use write-all merely because a step failed with a 403; that hides which call needs authority and gives unrelated steps more access.

Sources: Workflow syntax for GitHub Actions — GitHub Docs · Use GITHUB_TOKEN for authentication in workflows — GitHub Docs

Worked example: tests can read, release can write

The fragment below describes a reviewed scenario: test reads the repository; release reads the repository, creates a GitHub deployment record, and requests an OIDC token for a separate cloud provider. It is intentionally partial YAML. It omits triggers, conditions, runner selection, steps, environment protection, and provider trust configuration, so it is not a runnable workflow or a recommended copy-paste deployment. Add only the scopes your actual steps need after reviewing those missing controls.

Illustrative permissions fragment — incomplete workflow
# Illustrative only: add reviewed triggers, conditions, runners, and steps.
permissions: {}
jobs:
  test:
    permissions:
      contents: read
  release:
    needs: test
    permissions:
      contents: read
      deployments: write
      id-token: write

At the workflow level, permissions: {} removes the default GITHUB_TOKEN grants. The test job then grants itself contents: read, with other named permissions at none. The release job states its own complete set; it does not inherit the test job's map, and the test job does not receive the release job's writes. deployments: write is present only because this scenario creates a GitHub deployment record. If your deployment tool never calls that GitHub API, remove it. The release job still needs a trusted event and any required environment approval before those scopes should be available.

Before adopting the fragment, inspect each step's documentation and the workflow event. Some actions need access beyond contents: read, while others use their own credentials and ignore GITHUB_TOKEN permissions for the operation you care about. A checkout failure, a package upload, and a deployment creation are different permission questions. Change one job's map at a time and run the actual trigger path. Read the failed API operation rather than broadening every job to make a red run disappear.

Sources: Workflow syntax for GitHub Actions — GitHub Docs · Use GITHUB_TOKEN for authentication in workflows — GitHub Docs

OIDC permission is a token request, not cloud authorization

The name id-token: write is easy to misread. GitHub documents that it lets the job request an OIDC JSON Web Token; it does not by itself grant write access to the repository or to a cloud resource. The cloud provider must separately trust the relevant GitHub identity and decide which claims, such as repository, branch, or environment, are allowed to exchange that token for cloud credentials. A broad provider trust rule can undermine a carefully scoped GitHub job. Review both sides of the boundary.

In the example, release has both deployments: write and id-token: write because it performs two distinct operations. The first can create or update GitHub deployment records. The second can request an identity assertion. Neither is a substitute for the other. If release deploys through a provider and never writes a GitHub deployment, keep the OIDC permission if needed and remove deployments: write. If it writes only a GitHub deployment and uses no cloud federation, remove id-token: write. A short list of necessary operations is easier to audit than a preset with every permission.

Provider trust should be narrow enough that an unrelated repository or untrusted branch cannot exchange a token for the release role. GitHub's cloud-provider guidance says to define conditions so untrusted repositories cannot request credentials for the resource. Environment protection can add an independent gate for the release job. These controls live outside the permissions map, which is why a YAML review must include the provider configuration and the trigger path, not only the three permission lines shown above.

Sources: OpenID Connect reference — GitHub Docs · Configuring OpenID Connect in cloud providers — GitHub Docs · Workflow syntax for GitHub Actions — GitHub Docs

Forks and Dependabot do not become trusted releases

A pull request from a fork is not equivalent to a trusted push. GitHub's permission calculation normally adjusts write grants down to read-only for forked pull request events, unless an administrator has enabled sending write tokens to those workflows. Dependabot-triggered pull request workflows also have read-only GITHUB_TOKEN behavior by default, with separate secret rules. A permissions stanza that looks write-capable in the file may therefore run with less access. Diagnose the event and effective permissions before treating a 403 as a request to weaken repository policy.

Do not switch to pull_request_target just to make a test job write-capable. GitHub warns that this event runs with the base repository's token and secrets, and that checking out and executing an untrusted pull request in that context is dangerous. Keep code from outside contributors in the lower-trust test path. If a privileged action is genuinely necessary, design a separate trusted path that consumes only the minimum reviewed data and does not execute the contributor's code with a privileged token.

Dependabot deserves its own diagnosis because its event restrictions are not identical to every fork scenario. GitHub's documentation describes read-only defaults and which secret stores are available for Dependabot-triggered runs. A dependency-update test should usually succeed without release credentials; if it fails because the workflow assumes those credentials, separate the test from release rather than granting the update job broad authority. The key question is which job truly needs to write, on which event, under whose reviewed policy.

Sources: Workflow syntax for GitHub Actions — GitHub Docs · Dependabot on GitHub Actions — GitHub Docs · Securely using pull_request_target — GitHub Docs

Check the token the step actually uses

The permissions key controls the workflow's GITHUB_TOKEN, a token GitHub creates for the job. It does not shrink the rights of an unrelated GitHub App installation token or a personal access token that a step receives from a secret. Nor does a token permission automatically grant a secret to a job. When auditing a step, identify its credential source first: implicit GITHUB_TOKEN, an explicit token input, an App-generated token, a stored secret, or a cloud credential obtained by OIDC. Then inspect the corresponding permission and trust boundary.

A third-party action may access GITHUB_TOKEN through the github.token context even when it is not passed as an explicit input, according to GitHub's token guide. That is one reason to keep the token narrow in every job, including the test job. If a job needs a separate App token to act across repositories, review that App's repository installation and permissions on their own terms. Storing the token as a secret does not turn its privileges into the job's GITHUB_TOKEN permissions.

GitHub now also describes cache-mode, a separate control for cache restore and save access. Its scoped cache tokens are not governed by the GITHUB_TOKEN permissions map. A test job that is read-only for repository APIs can still have a cache behavior worth reviewing, particularly on low-trust triggers. Keep cache policy as a separate line in the security review rather than claiming that contents: read or permissions: {} determines it.

The final review can be short: name each job's intended operation, its effective GITHUB_TOKEN scopes, any other credential it receives, and the events on which it runs. The test job should have no release authority; the release job should have only the specific GitHub and provider powers its reviewed path needs. Run the workflow and inspect the effective failure if one appears. A successful test proves that the declared permissions support that event path, while a separate review of the trigger, secrets, and provider trust explains whether the path is safe to ship.

Sources: Use GITHUB_TOKEN for authentication in workflows — GitHub Docs · Workflow syntax for GitHub Actions — GitHub Docs · Secrets — GitHub Docs

Sources and verification

Browse all resources