Skip to content
audiotale.net
Sign in
GuidesGuide

How to harden GitHub Actions against stolen tokens: permissions, OIDC and pinning

After the wfx-loot campaign, a step-by-step guide to least-privilege tokens, short-lived OIDC cloud access, environment reviews and action pinning, based on GitHub's own documentation.

audiotale.net team

· 8 min read

Written by AI from the sources linked below. How we use AI

On this page
  1. In short
  2. Why this matters now
  3. Step 1: Understand what a workflow can reach
  4. Step 2: Shrink what each token and secret can do
  5. Step 3: Replace long-lived cloud keys with OIDC
  6. Step 4: Control who can change workflow files and secrets
  7. Step 5: Pin and audit the actions you use
  8. Step 6: Handle untrusted pull requests carefully
  9. Step 7: Mind your runners
  10. A short checklist
  11. Where this guide stops
  12. Sources

In short

A GitHub Actions workflow is code that runs with your secrets, so anyone who can change a workflow file, or whose stolen token can, can usually read those secrets. GitHub's own security guidance boils down to a handful of habits: give every token the smallest permissions, replace long-lived cloud keys with short-lived OpenID Connect (OIDC) tokens, lock down who can change workflow files, pin the actions you use, and be careful with workflows that run on untrusted pull requests. None of them is a complete defence alone, so the point is to stack them. The campaign we covered this week, wfx-loot, shows why: attackers used real stolen tokens, so no software bug needed fixing.

Why this matters now

According to Aikido, the wfx-loot attacker opened a pull request in each repository a stolen token could write to and then injected a script that collected Actions secrets, cloud credentials and AI provider keys. The key observation for defenders is that this is not a flaw in GitHub Actions. The attacker acted with permissions that had been legitimately granted to the stolen accounts. So the useful question is not "how do I stop this one payload?" but "what could a workflow, or someone who can edit one, reach in my setup, and how little could that be?"

This guide walks through that question using GitHub's published guidance: the secure use reference and the OpenID Connect overview. We name the source for each recommendation, and where a point is our own judgement we say so.

Step 1: Understand what a workflow can reach

GitHub's documentation gives three facts that shape everything else.

  1. Write access means secret access. In GitHub's words: "Any user with write access to your repository has read access to all secrets configured in your repository." In practice this means anyone who can push a branch with a modified workflow can often make a job print or send those secrets.
  2. One job can affect others. The docs explain that jobs in a workflow can interact with each other, for example by reading environment variables or shared files, so a single compromised action can reach every secret configured on the repository and may use the GITHUB_TOKEN to write to it.
  3. Redaction is not a safety net. Logs mask secrets by exact match, so a transformed value (Base64, URL-encoded, or a secret stored as a JSON blob) may not be masked. GitHub also says that automatic redaction is not guaranteed.

Start with an inventory. For each repository, list the secrets, the cloud roles its workflows can assume, and who has write access. This is the "blast radius" we refer to below. It is our suggested exercise, not a step from the docs, but every later step depends on it.

Step 2: Shrink what each token and secret can do

Set the default GITHUB_TOKEN to read-only and raise it per job. GitHub calls it good security practice to set the default permission for the GITHUB_TOKEN to read access only for repository contents, and then increase permissions for individual jobs as needed. A minimal example:

permissions:
  contents: read

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write   # only the job that needs OIDC

Apply least privilege to every credential in a workflow. GitHub says the credentials used in workflows should have the least privileges required. A deploy key that can write to one bucket is a smaller problem than an admin key that can do everything.

Make one secret per value. The docs advise against using a blob of JSON, XML or YAML as a secret, because redaction depends on finding an exact match. Create individual secrets for each sensitive value, and register any value you derive from a secret (for example a signed JWT) as a secret too.

Delete and rotate exposed secrets. If an unredacted secret reaches a log, GitHub says to delete the log and rotate the secret.

Audit and rotate on a schedule. GitHub recommends reviewing registered secrets periodically, removing those no longer needed, and rotating the rest to shorten the time a stolen secret stays valid.

Step 3: Replace long-lived cloud keys with OIDC

This is the change with the biggest payoff. Today many workflows store a cloud access key as a GitHub secret and present it on every run. GitHub's OIDC documentation describes the alternative: you establish a trust relationship in your cloud provider (AWS, Azure, GCP, HashiCorp Vault and others), and each job requests a short-lived token directly from the provider instead.

How it works, per GitHub's overview:

  1. You create the OIDC trust on the cloud side, allowing specific GitHub workflows to request access on behalf of a defined cloud role.
  2. Each time a job runs, GitHub's OIDC provider generates a token (a JWT) unique to that job. It carries claims such as repository, ref, environment, workflow and job_workflow_ref, and a subject (sub) that identifies the workflow.
  3. The cloud provider checks those claims against the conditions on the role's trust definition.
  4. If they match, it returns a short-lived cloud token valid only for the job.

The docs list the benefits as no long-lived cloud secrets in GitHub, finer-grained control through your cloud provider's own authorisation tools, and automatic credential rotation, because the token expires after the job.

The catch, which matters for wfx-loot: OIDC is only as safe as the trust conditions you write. The cloud provider checks the subject and other claims against what you configured. If the trust policy accepts a broad subject, such as any branch in the repository, then an attacker who can run a workflow on any branch may still obtain cloud access. Narrow the conditions, for example to a specific repository, a protected branch or a named environment, and review them. According to Aikido, the wfx payload mints OIDC tokens and tries to exchange them for AWS, GCP and Azure sessions wherever the repository is trusted, so a loose trust policy is exactly what it looks for. (How tightly you can scope the claims depends on your cloud provider; GitHub notes, for instance, that custom claims are unavailable in AWS.)

Also note a recent change in the docs: repositories created after 15 July 2026 use an immutable default subject format that includes the owner and repository IDs, rather than names. If you maintain trust policies by hand, check which format your repositories emit.

Step 4: Control who can change workflow files and secrets

Protect secrets with environments and required reviewers. GitHub says you can use required reviewers to protect environment secrets: a job cannot access environment secrets until a reviewer approves. Putting production credentials in an environment, instead of the repository, adds a human gate.

Use CODEOWNERS on .github/workflows. The docs suggest adding the workflows directory to the code owners list so that any proposed change needs approval from a designated reviewer.

Keep branch protection, but know its limits. Aikido reports that the wfx payload posts forged success status checks using the names of a repository's required checks, because branch protection matches on the check's name, not on who posted it. Required human reviews, not only required checks, are therefore worth having. This is our inference from Aikido's description, not a statement in GitHub's guidance.

Limit workflows that approve pull requests. GitHub notes you can prevent Actions from creating or approving pull requests, since automation that approves its own changes can be a risk if merged without oversight.

Secure the accounts themselves. Our reading of wfx-loot is that the root problem is stolen developer credentials. Keep tokens short-lived and narrowly scoped, review which repositories each person and integration can write to, and remove access that is not used. GitHub's secure-use page is about workflows; account hygiene is our addition.

Step 5: Pin and audit the actions you use

GitHub's documentation is blunt: a compromised action in a workflow can reach every secret configured on the repository. It lists, in order of strength:

  • Workflow dependency lockfiles. These record the exact commit of every action your workflows depend on, including transitive ones, and verify the source repository's identity at run time. The docs call this the strongest protection and say it supersedes SHA pinning for repositories that adopt it.
  • Pin to a full-length commit SHA. A bad actor would have to find a SHA-1 collision to slip in a backdoor. Verify the SHA comes from the action's repository and not a fork.
  • Pin to a tag only if you trust the creator. Tags can be moved or deleted if an attacker gains access to the action's repository.

Whatever you choose, audit the action's source: check that it does not send secrets to unexpected hosts or print them. Dependabot can then keep pinned references updated, and the docs mention it for exactly that.

Step 6: Handle untrusted pull requests carefully

Two classes of mistake come up in GitHub's guidance.

Script injection. If a workflow puts untrusted values such as a pull request title straight into a run: script, an attacker can craft a title that becomes shell commands. GitHub recommends passing the value through an intermediate environment variable (as in the example env: TITLE: ${{ github.event.pull_request.title }}) or to an action as an argument, so it is treated as data.

Privileged triggers. The pull_request_target and workflow_run triggers run with more privilege, sharing the main branch's cache and possibly having write access and secrets. GitHub says to avoid them if you do not need them, and never to check out untrusted pull request code in such a workflow. It also points to GitHub Security Lab's "Preventing pwn requests" and OpenSSF Scorecards, which flag risky patterns, including token permissions and unpinned actions.

CodeQL's default setup can scan for vulnerable workflow patterns, per the docs.

Step 7: Mind your runners

GitHub-hosted runners run each job in an ephemeral, isolated virtual machine, so a compromise does not persist. Self-hosted runners offer no such guarantee. GitHub says they should almost never be used for public repositories, because anyone can open a pull request. If you use them, GitHub recommends limiting them with runner groups, keeping sensitive data off the machine, and using just-in-time (JIT) runners, which perform at most one job before being removed. Aikido adds that the wfx payload collects instance-metadata credentials on self-hosted runners.

A short checklist

  1. Inventory secrets, cloud roles and write access per repository.
  2. Default GITHUB_TOKEN to contents: read; grant more per job.
  3. Move cloud access to OIDC, with narrow trust conditions.
  4. Put production secrets in environments with required reviewers.
  5. Add CODEOWNERS for .github/workflows, and require human review.
  6. Pin actions to commit SHAs, or adopt dependency lockfiles.
  7. Avoid privileged triggers on untrusted code; pass untrusted values through environment variables.
  8. Use hosted or JIT runners; keep self-hosted runners away from public repositories.
  9. Rotate secrets regularly and know your procedure for a bad day: if a payload ran, treat everything the workflow could reach as compromised, as Aikido advises for wfx-loot.

Where this guide stops

This guide covers workflow design. It does not cover endpoint security for developers' laptops, or incident response for a repository that is already compromised. For the latter, our wfx-loot article lists the indicators Aikido published and what to check in AWS and Azure. Settings and names in GitHub's documentation change, so check the linked pages before you apply an example.

Sources

Written by

audiotale.net team

The official account of the people who run audiotale.net: announcements, conversation starters and the newsroom's drafts. Posts written with AI say so. Every other account here is a real person.