>
Software

Tighten GitHub Actions tokens before they widen an attack

A GitHub Actions workflow that runs green is not the same thing as a GitHub Actions workflow that is safe. The default token (an auto-generated credential GitHub gives every workflow run) has more scopes than most jobs actually use, and the green checkmark hides that gap until something goes wrong. Tightening that scope is one of the few CI changes (continuous integration, meaning the automated pipeline that runs every time you push code) you can make in an afternoon that will meaningfully reduce what a compromised step, a typo in a third party action, or a malicious pull request can do to your repository.

This walkthrough covers the smallest set of changes that actually moves the needle, and the order in which to make them so you do not break a working pipeline halfway through.

What the default token actually has

When a workflow does not declare a permissions: block, the job token is created with the repository’s default token scope. On most repositories that scope is broad enough to push code, write issues, edit packages, and trigger downstream workflows. The workflow still passes all the tests, so nobody audits it. Then one of three things happens:

  1. A third party action (a pre-built automation step written by someone outside your team and pulled in by name) has a vulnerability and the same broad token gets exfiltrated (snuck out of the pipeline).
  2. A pull request from an untrusted fork runs a workflow that should have been read-only and ends up writing a release.
  3. A typo in a custom step posts to the wrong branch.

The fix is to declare permissions explicitly at the workflow or job level so the token is generated with the smallest scope that still lets the job run. The remaining tokens are still scoped to the single workflow run, so they expire when the job finishes, and they cannot be reused outside the workflow context. That is a meaningful constraint, but it does not protect against the cases above. A token that can write releases inside one workflow run is exactly the token an attacker wants, because they only need it once.

Two more places the default scope hides, which the source we drew from did not go deep on:

  • Patterns resembling pull_request_target from a fork, where the workflow runs with the base repository’s scope and the fork’s code. Based on the broader public Actions CVE history, this is a recurring pattern in workflow-level disclosures.
  • Reusable workflows invoked from a callable pattern, where the called workflow may inherit the caller’s token unless you reset it explicitly. From our read of the surrounding GitHub docs, that is the kind of inheritance that is easy to miss.

Both of these patterns are common enough, based on what we have seen in community write-ups, that the safest default is to assume the default token is too broad and shrink it.

The smallest change that pays off first

Start with one workflow. At the top of the workflow file, just below the on: trigger, add a permissions: block that grants read-only access to repository content. That single line is the difference between a token that can push code and a token that can only read it.

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

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

With this baseline, the workflow can check out code and run tests, but it cannot push to a branch, create a release, or edit issues. The build stays green assuming your job does not need write access, which most test jobs do not.

Run the workflow once. If it still passes, you have just removed the most common attack surface in the pipeline without touching anything else.

Push write access to the job that actually needs it

For most repositories, the only job that needs write access is the release or publish job. Everything else should stay read-only. Declare the broader scope at the job level, not the workflow level, so the rest of the pipeline stays narrow.

release:
  runs-on: ubuntu-latest
  permissions:
    contents: write
  needs: test
  steps:
    - uses: actions/checkout@v4
    - run: ./scripts/release.sh

The job that publishes the package gets the write token. The job that runs lint, the job that runs unit tests, and the job that builds the artifact all stay at contents: read. That separation is what makes the workflow auditable. A reviewer can read the YAML and see exactly which job is allowed to push commits, and the rest of the pipeline is provably inert.

If you have multiple write jobs, repeat the pattern. Each job declares its own permissions: block. Do not be tempted to set permissions: write-all at the workflow level because it is one less line to maintain. That line is the line an attacker is hoping you wrote.

Replace long-lived cloud credentials with OIDC

When the workflow deploys to AWS, Azure, or GCP, the classic mistake is to store a static access key in a repository secret (an encrypted environment variable that is injected into the workflow at runtime) and call it from the job. That secret has the same permissions every time it is used, and it lives in the workflow file’s environment for anyone with read access to the repository to see.

OIDC (OpenID Connect, a standard for short-lived federated authentication) replaces that static secret with a temporary token that the cloud provider issues to the job at runtime. The job proves its identity to AWS or Azure, the provider checks a trust policy (a rule that says “this repository, this workflow, this branch is allowed to assume this role”), and grants a short-lived role. The token expires when the job ends.

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure cloud credentials
        run: echo "Configure OIDC-based credentials here"

The id-token: write permission is what lets the workflow request the OIDC token. The actual cloud-side role assumption happens in the deploy step. The static secret is gone, and there is nothing to rotate when someone leaves the team.

Trade-offs

The least-privilege approach is not free. A few things to weigh:

  • Cold start cost. The first time you shrink a workflow’s permissions, you will break something. Plan for one or two iterations where a test step fails because it could not write to a branch. The fix is to declare the narrower scope at the job level, not to widen the workflow back to the default.
  • Action compatibility. Some third-party actions assume broad scopes and access things they did not declare. Pin actions to a specific commit SHA (a 40 character hash that uniquely identifies one exact version of the action’s source code) rather than a tag, and review what each action’s documentation says about the permissions it needs.
  • OIDC setup is front-loaded. Moving from static secrets to OIDC requires a one-time trust policy and role configuration on the cloud provider. That is an afternoon of work, not a five-minute change. It pays back the first time you would have had to rotate a leaked key.
  • Cross-workflow inheritance. If your organization enables workflow_call with shared secrets, the permissions you set on the caller do not automatically propagate to the called workflow. Each called workflow needs its own permissions block.

How to verify the tightening stuck

A small test branch is the cheapest way to confirm the workflow still works after the change. Push a commit that triggers the workflow and check three things:

  1. The read-only job still passes.
  2. The release job only succeeds when it has explicit write permission.
  3. A deliberate failure case, like a release step where the job only has contents: read, fails fast instead of silently dropping the write.

If a security scanner or linting step is part of the workflow, keep it in place after the change. The scanner is what surfaces new permissions violations in future pull requests, so removing it would undo the audit trail you just built.

A useful habit is to compare the diff of the workflow YAML before and after the change. The new version should be shorter, not longer, because you are deleting scope rather than adding configuration. If the diff is bigger than the workflow, you have probably added a workaround you will need to remove later.

The strongest signal is a workflow that passes with the narrowest permission set you can justify, and that fails immediately when a job reaches for a scope it does not have. That is a workflow you can defend in a review, a postmortem, or a security questionnaire without rereading the YAML. The simplest version of this is the workflow that you can show in a single screen and explain in one sentence: “this job can read code, this job can publish, and nothing else can do anything.”

Leave a comment