A GitHub Actions job is a good place to steal secrets from. It holds deploy keys, registry tokens and cloud credentials, and it runs code that someone else wrote a minute ago: third-party actions, packages and their install scripts. GitHub's secret handling protects against accidents. It was not designed to stop code that is already running inside the job.

This post covers how secrets work in GitHub Actions, the five ways they leak in practice, and the controls that close each one.

How GitHub Actions secrets work

Secrets live at three levels: organization, repository and environment. When the same name exists at more than one level, the most specific wins: environment over repository, repository over organization.

A few limits and rules worth knowing:

  • Size and count. Up to 1,000 organization secrets, 100 repository secrets and 100 environment secrets, each at most 48 KB. A workflow sees every repository and environment secret, but only the first 100 organization secrets in alphabetical order.
  • Forks get nothing. Apart from GITHUB_TOKEN, secrets are not passed to workflows triggered by a pull request from a fork.
  • Dependabot is treated like a fork. Workflows triggered by Dependabot get a read-only token and only Dependabot secrets, not Actions secrets.
  • Reusable workflows need secrets passed in. secrets: inherit passes all of them implicitly, within the same organization or enterprise. Environment secrets cannot be passed through workflow_call.
  • Environments gate access. With required reviewers, a job cannot read an environment's secrets until someone approves it. Deployment branch rules limit which branches can use the environment at all.

What masking does, and what it doesn't

GitHub redacts secret values from logs, but redaction relies on finding an exact match. GitHub's own documentation lists where it fails:

  • a secret inside structured data such as JSON or YAML
  • a value derived from a secret, such as a token exchanged for another token, unless you register it with ::add-mask::
  • a secret that has been transformed, for example base64 encoded

Masking is a guard against printing a secret by mistake. It does nothing against code that encodes the secret first.

How secrets leak from workflows

1. A compromised action prints them

In March 2025 an attacker compromised tj-actions/changed-files (CVE-2025-30066), an action used by more than 23,000 repositories. They moved the action's existing version tags to a malicious commit, so every workflow that referenced a tag such as @v45 ran the attacker's code. Only workflows pinned to a full commit SHA were safe.

The payload read secrets out of the runner's memory and printed them to the job log, base64 encoded twice so masking would not catch them. On public repositories those logs are readable by anyone. The compromise started from another action, reviewdog/action-setup (CVE-2025-30154), and before that from a personal access token stolen through a pull_request_target workflow.

2. A pull_request_target workflow runs untrusted code

pull_request_target runs in the context of the base repository, with its secrets and a token that can write, even when the pull request comes from a fork. That is safe only as long as the workflow never runs code from the pull request. When a workflow checks out the pull request's head and builds it, anyone who can open a pull request can read every secret the workflow has. GitHub Security Lab calls these "pwn requests".

GitHub has been narrowing this. Since December 2025, pull_request_target always uses the workflow file from the default branch. Since July 2026 actions/checkout refuses to check out fork code in pull_request_target workflows that use known-dangerous patterns, and workflow execution protections, generally available since September 2026, will disable pull_request_target by default in public repositories without an event policy from November 2, 2026.

3. Untrusted input is injected into a script

Workflow expressions are expanded before the shell runs. A step like this:

- run: echo "Title: ${{ github.event.issue.title }}"

executes whatever the issue title contains. An attacker opens an issue titled with a shell command and the job runs it, with the job's secrets in reach. Pass untrusted values through an environment variable instead, so the shell treats them as data:

- env:
    TITLE: ${{ github.event.issue.title }}
  run: echo "Title: $TITLE"

4. Caches and artifacts carry them out

  • Artifacts. In 2024 Unit 42's ArtiPACKED research showed that actions/checkout writes GITHUB_TOKEN into .git/config by default, so uploading the workspace as an artifact leaks the token to anyone who can download it. upload-artifact has excluded hidden files by default since September 2024, and actions/checkout v6 stores the credential outside the checkout directory, but older pinned versions and custom upload steps still leak.
  • Caches. An attacker who can run code in any workflow of a repository can fill its cache, push the real entries out and plant a poisoned one under the same key. A later privileged workflow restores it. In May 2026 this chain, starting from pull_request_target and ending with an OIDC token read from runner memory, let an attacker publish 84 malicious versions of 42 TanStack packages.

5. A dependency sends them to a server

Install scripts run with the same access as the rest of the job. The Shai-Hulud worm, first seen on npm in September 2025, ran during npm install, collected npm tokens, GitHub tokens and cloud keys from the environment and posted them to public GitHub repositories. Its November 2025 wave ran at preinstall and registered the infected machine as a self-hosted runner, so the attacker could come back later.

This route needs no mistake in your workflow. A single compromised version anywhere in the dependency tree is enough, and the secrets leave over the network.

How to lock them down

Replace stored cloud keys with OIDC. Instead of keeping an AWS, Azure or Google Cloud key as a secret, let the job request a short-lived token from GitHub's OIDC issuer and exchange it with the cloud provider. The trust policy decides which repository, branch or environment may assume the role, and the credential expires with the job. The job needs permissions: id-token: write. OIDC tokens can still be stolen from a running job, as TanStack showed, but a stolen token is worth far less than a key that never expires.

Pin actions to a full commit SHA. GitHub's documentation calls it the only way to use an action as an immutable release. Tags can be moved; a SHA cannot. Since August 2025 the allowed-actions policy can enforce SHA pinning and block specific actions across an organization. Dependabot and Renovate can keep pinned SHAs up to date.

Give GITHUB_TOKEN the least it needs. Set the default to read-only and grant write scopes per job. New organizations and repositories have defaulted to read-only since February 2023, but older ones kept the permissive default unless someone changed it. See GITHUB_TOKEN permissions: least privilege for every workflow.

Scope secrets to the step that needs them. A secret in a step's env: is only in that step's environment. A secret set at job level is visible to every step, including every third-party action.

Put production secrets behind environments. Required reviewers and deployment branch rules mean a workflow on a feature branch, or one triggered by a pull request, cannot read them.

Treat pull_request_target and untrusted input as code execution. Avoid pull_request_target unless you need it, never build pull request code in it, and pass event data through environment variables. CodeQL can check workflows for injection and pwn request patterns; it has been generally available for GitHub Actions since April 2025.

Turn on secret scanning and push protection. They catch secrets committed to the repository, which is a different leak from the ones above but just as common.

Restrict where the job can connect. Most jobs need a short list of destinations: package registries, container registries and a few APIs. If the job can reach only those, a secret read by malicious code has nowhere to go. See how an egress policy stops exfiltration from GitHub Actions.

Rotate after any exposure. If a workflow ran a compromised action or package, assume every secret it could read was taken. Revoke and reissue them all, not only the ones you think were used.

Where CRACI fits

CRACI runs your GitHub Actions jobs on its own runners: you change runs-on to craci, and the workflows, secrets and run history stay in GitHub. Two things it does are aimed at the leaks above.

  • An egress policy that fails closed. You set which package sources, registries and hosts a job may reach. The policy is validated before the job starts and enforced at the runner, so a connection to anywhere else is blocked, and you get an alert when the policy is violated.
  • A record of what the job did. CRACI records every package each job actually fetched, including packages restored from caches, and a trace of the job's network connections. When the next compromised package is announced, you can see which builds pulled it and what they connected to, instead of guessing from lockfiles.

Each job runs in its own virtual machine, so nothing one job leaves behind is there for the next. CRACI does not scan for secrets in your code; keep GitHub's secret scanning for that.

Book a demo to see the record of one of your own builds.