Introducing Tenki's code reviewer: deep, context-aware reviews that actually find bugs.Try it for Free
Guide on securing GitHub Actions by locking down GITHUB_TOKEN permissions
Hayssem Vazquez-Elsayed
Hayssem Vazquez-ElsayedFeb 22, 2026
GitHub Actions SecurityGITHUB_TOKENOpenID Connect

GitHub Actions Permissions: Lock Down GITHUB_TOKEN


Author


Hayssem Vazquez-Elsayed
Hayssem Vazquez-Elsayed

Share


TL;DR

Default GITHUB_TOKEN permissions are dangerously broad. Here's how to scope every job to minimum access and eliminate static cloud credentials with OIDC.

Every GitHub Actions workflow gets a token. It's called GITHUB_TOKEN, and it can read your code, write to your repo, push packages, close issues, and create deployments. By default, on many repositories, it can do all of that at once. If a compromised third-party action runs inside your workflow, it inherits every permission that token carries.

That's the problem this article solves. We'll walk through exactly how the token works, why the defaults are dangerous, how to lock permissions down to the minimum each job actually needs, and how OIDC lets you drop static cloud credentials entirely.

How GITHUB_TOKEN actually works

When you enable GitHub Actions on a repository, GitHub installs a GitHub App behind the scenes. Before each job starts, GitHub uses that app to mint a short-lived installation access token scoped to the repository. That token is GITHUB_TOKEN.

A few things to understand about its lifecycle:

  • Per-job lifetime. A fresh token is created for each job. It expires when the job finishes, or after the runner's maximum execution time (6 hours for GitHub-hosted runners, up to 24 hours for self-hosted).
  • Implicit access. Actions can access the token through the github.token context even if you never pass it explicitly. You don't have to write ${{ secrets.GITHUB_TOKEN }} for an action to use it.
  • No recursive triggers. Events caused by the token (like pushing a commit) won't trigger new workflow runs, with the exceptions of workflow_dispatch and repository_dispatch. This prevents infinite loops.
  • Repository-scoped. The token can only access the repository that contains the workflow. It can't reach across to other repos in your organization.

The repository scope sounds reassuring, but the damage a token can do within a single repo is substantial. Write access to contents means an attacker can push code. Write access to packages means they can publish malicious artifacts under your org's name.

The default permissions problem

GitHub offers two default permission modes for the token, configured at the organization or repository level:

  • Permissive (read and write) — The token gets read/write access to most scopes by default. This was the original behavior and is still the default on many older repositories.
  • Restricted (read-only) — The token only gets read access to contents and metadata. Everything else requires explicit grants in the workflow file.

Repositories created under enterprise or organization accounts with updated policies now default to restricted mode. But if your org hasn't changed this setting, or if you're on a personal account with older repos, you're likely running permissive.

Forked repositories add another layer. When a pull_request event fires from a fork, the token is automatically downgraded to read-only regardless of your settings, and secrets aren't exposed. This is a safety net. But pull_request_target bypasses it, which we'll cover in detail.

The permissions key: workflow-level vs. job-level

The permissions key is how you override defaults. You can place it at the top level of the workflow (applies to all jobs) or inside a specific job (overrides the workflow-level setting for that job only).

Here's the critical behavior: when you specify any individual permission, all unspecified permissions are set to none. This is called scope zeroing, and it's the entire mechanism that makes least-privilege work. If you write contents: read and nothing else, then issues, packages, deployments, and every other scope drop to none.

Two shorthand values exist: write-all grants write access to every scope. read-all grants read access to every scope. Using write-all in a workflow file is essentially opting out of the security model. Don't do it.

The full scope list

Each permission scope maps to a set of GitHub API endpoints. You can set each to read, write (which includes read), or none. Here's what they control:

  • actions — Manage workflow runs (cancel, re-run, view logs)
  • attestations — Generate and verify artifact attestations for build provenance
  • checks — Create and update check runs and check suites
  • contents — Read commits, create releases, push tags, manage repo files
  • deployments — Create and manage deployment statuses
  • id-token — Request an OIDC JWT for cloud provider authentication (write-only, no read)
  • issues — Create, edit, close issues and comments
  • packages — Upload and publish to GitHub Packages (npm, Docker, Maven, etc.)
  • pages — Manage GitHub Pages deployments
  • pull-requests — Comment on, approve, or merge pull requests
  • security-events — Read and write code scanning alerts and SARIF uploads
  • discussions — Close or delete GitHub Discussions

Most CI jobs only need contents: read to check out code and run tests. Granting anything beyond that without a clear reason is giving away access you don't need to give.

Job-level permissions in practice

The strongest approach is setting permissions at the job level rather than the workflow level. Each job gets exactly the scopes it needs, and nothing more. Here's a real-world workflow with three jobs that each declare their own permissions:

name: CI Pipeline
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  release:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: write
      packages: write
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  label-pr:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/labeler@v5

Look at what each job can and can't do:

  • The test job can read code. It can't push commits, publish packages, or comment on PRs. If a test dependency gets compromised, the blast radius is minimal.
  • The release job can write contents and packages because it needs to publish. It only runs on main, after tests pass. It can't touch issues or PRs.
  • The label-pr job can write to pull requests. It can't read repo contents beyond what's needed, can't push code, can't publish.

Compare that to a workflow with permissions: write-all at the top, where every job can do everything. A compromised labeler action could push code to main.

The pull_request_target trap

This is the single most dangerous footgun in GitHub Actions, and it catches experienced teams.

pull_request events from forks run with read-only permissions and no access to secrets. That's safe. pull_request_target was designed for a different use case: it runs in the context of the base repository, with the base repo's secrets and a read/write GITHUB_TOKEN, even when the PR comes from a fork.

The intended use is labeling PRs or posting comments without needing to trust the fork's code. The problem happens when a workflow checks out and executes the PR's code:

# DANGEROUS: do not do this
on: pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm test  # executes attacker-controlled code

This is called a "pwn request" attack. The workflow checks out the attacker's code from the fork, then runs it with the base repo's elevated token and secrets. The attacker can exfiltrate secrets, push malicious code to your default branch, or publish compromised packages.

The correct pattern when you need pull_request_target is to never check out or execute the PR's head ref. Only use it for metadata operations:

# Safe: only reads PR metadata, never checks out fork code
on: pull_request_target

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/labeler@v5

If you genuinely need to build fork code and then perform a privileged action (like posting a coverage comment), split it into two workflows. The first uses pull_request to build and upload artifacts. The second uses workflow_run to download those artifacts and post results with elevated permissions, never executing the fork's code directly.

OIDC: replacing static cloud credentials

Even with locked-down GITHUB_TOKEN permissions, many workflows still store long-lived AWS keys, GCP service account JSON, or Azure credentials as repository secrets. If those leak, an attacker has persistent access to your cloud infrastructure.

GitHub Actions supports OpenID Connect (OIDC) as an alternative. Instead of storing a secret, your workflow requests a short-lived JWT from GitHub's OIDC provider and exchanges it with your cloud provider for temporary credentials. The JWT includes claims about the repository, branch, workflow, and job, so your cloud provider's trust policy can restrict access precisely.

To use OIDC, you need the id-token: write permission in the job. Here's an AWS deployment example:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://my-bucket

No AWS_ACCESS_KEY_ID stored anywhere. The aws-actions/configure-aws-credentials action handles the OIDC token exchange automatically. On the AWS side, you create an IAM OIDC identity provider for token.actions.githubusercontent.com and a role with a trust policy that restricts the sub claim to your specific repo and branch.

The same approach works for GCP (Workload Identity Federation) and Azure (federated identity credentials). If your cloud provider supports OIDC, there's no reason to keep static keys in your repository secrets.

Auditing your workflows

If you've got existing workflows that haven't been locked down, here's a practical checklist:

  1. Set the org/repo default to restricted. Go to Settings > Actions > General and change the default GITHUB_TOKEN permissions to "Read repository contents and packages permissions." This forces every workflow to declare what it needs.
  2. Grep for write-all and missing permissions keys. Any workflow without a permissions key is using the repo default. Any workflow with permissions: write-all needs to be refactored.
  3. Move permissions to job level. Workflow-level permissions are a reasonable starting point, but job-level gives you finer control. A workflow that tests, builds, and deploys should not give the test job deploy-level access.
  4. Search for pull_request_target. Every instance needs manual review. If the workflow checks out the PR head ref, it's vulnerable. Refactor to the two-workflow pattern described above.
  5. Migrate cloud credentials to OIDC. For each cloud provider you deploy to, check whether they support OIDC federation. AWS, GCP, and Azure all do. Replace stored keys with id-token: write and a trust policy bound to your repo.
  6. Pin third-party actions to commit SHAs. Permissions only protect against token scope. A compromised action can still do damage within the permissions you've granted. Pin actions to full SHA hashes so a tag hijack doesn't inject malicious code.
  7. Use CODEOWNERS for .github/workflows/. Require review from a security-aware team member before any workflow file changes merge. This prevents a contributor from quietly widening permissions.

The whole point is defense in depth. Restricted defaults mean a missing permissions key is safe. Job-level scoping limits blast radius. OIDC eliminates credential theft. SHA pinning prevents supply chain attacks on actions themselves. None of these alone is sufficient. Together, they make your CI pipeline genuinely hard to exploit.

Related News


Get Tenki

Faster Builds. Smarter Reviews. Start Both For Free.

Change 1 line of YAML for faster runners. Install a GitHub App for AI code reviews. No credit card, no contract. Takes about 2 minutes.