Aug 04, 2026
17 min read

GitHub Actions secrets security: What can go wrong

GitHub Actions secrets security: What can go wrong

If someone told you that GitHub Actions would become one of the most exploited credential surfaces in software supply chains, your first instinct might have been to push back. GitHub Actions is a workflow runner and CI/CD tool that developers set up once and stop thinking about. That instinct is exactly what attackers count on.

In March 2026, a threat actor force-pushed malicious code into 75 of 77 version tags in the aquasecurity/trivy-action repository. A custom Python infostealer scraped raw process memory from active runners to harvest SSH keys, cloud credentials, and Kubernetes tokens.

The same actor fed those credentials into scanners, identified highly privileged Checkmarx credentials, and implanted an identical payload across that repository's action tags. The compromise cascaded into container registries and over 66 npm packages.

Two months later, a malicious fork PR against TanStack/router abused a privileged workflow trigger, poisoned a shared cache boundary, and allowed attackers to publish 84 malicious package versions across 42 packages in exactly six minutes.

None of these were novel exploits. They worked because of how GitHub Actions handles credentials and trust boundaries by default. This article examines the four attack classes behind incidents like these and what you can do about each one.

Attack class 1: Secrets in workflow logs

Some engineers assume that since repository strings marked as "Secret" in GitHub get masked, they are protected from exposure. But the vulnerability stems from a basic chronological reality inside the runner architecture: masking is a downstream regex filter; it doesn't protect the runner's memory. Two standout things can go wrong as a result of this.

The first is that since secrets still exist in the runner's memory and even in the shell context during execution, an attacker can steal them before masking applies. This is how the Python stealer in the Trivy attack was able to scrape runner memory and harvest credentials during execution.

The second is that masking can become ineffective if secrets are transformed through base64 encoding, string splitting, or hashing before masking. GitHub Actions' masking system mainly works by matching exact values it already knows. So changing the original secret value shape can make it unrecognizable to the matcher.

For example, a secret stored as AWS_SECRET_ACCESS_KEY=abcd1234secret can typically be masked and redacted in logs. However, if an attacker encodes the secret before output using:

GitHub may not recognize the encoded value and instead output it directly. This output can then be easily decoded and used by attackers. Even GitHub's own documentation states that automatic redaction cannot guarantee removal when secret values are transformed.

There are certain mitigations GitHub has put in place to reduce the likelihood of exposure.

One is setting GITHUB_TOKEN default permissions to read-only. This gives the auto-generated workflow token only contents: read permission at the organization level, and prevents leaked tokens from being used to force-push malicious code.

Another is the use of the ::add-mask:: workflow command for any computed values that contain secret material. So even if values become transformed, GitHub is still instructed to redact them from logs.

One more is using env variable injection instead of interpolating secrets directly into run steps. Interpolating secrets directly into shell commands or shell scripts makes them more likely to appear in logs, get echoed during debugging, or leak through shell tracing.

It is safer to separate the secret into the environment context instead:

While these mitigations help reduce the exposure of secrets in logs and outputs, they do not protect secrets during execution inside the runner's memory.

Attack class 2: Supply chain via third-party actions

Developers mostly prioritize the use of Git tags (for example, @v3 or @v2.2.0) when referencing third-party GitHub actions.

The problem is that these tags are mutable, and that creates security risks. An attacker who gains access to the third-party action repository can push malicious code to that tag.

GitHub Actions also does not require any reauthentication or downstream approval when a tag changes. A new workflow run silently pulls the updated code tied to that tag. This is similar to how attackers targeted 75 tags in the Trivy attack, exposing SSH keys, Docker credentials, cloud credentials, and other sensitive data. Even during remediation, attackers can still gain access to newly rotated credentials because revocation does not happen simultaneously across all active runners and workflows using the tokens. As Aqua's CTO, Itay Shakury, noted: "We rotated secrets and tokens, but the process wasn't atomic, and attackers may have been privy to refreshed tokens."

The same compromise later cascaded through the Checkmarx attack chain. Eventually, it reached the official Bitwarden CLI npm package, demonstrating how dangerous compromised third-party actions can become within interconnected supply chains.

GitHub's native mitigation for this attack class is to pin actions to full Git commit SHAs instead of mutable version tags.

The SHA points directly to a specific immutable commit, so GitHub workflows pinned to it would not automatically pull newly modified code if attackers later retarget a version tag.

However, SHA pinning still has a provenance problem. GitHub allows commit SHAs from forks to be resolved across the repository network. An attacker can create a malicious commit in their own fork and trick developers or automation tooling into referencing that SHA directly. If teams are not attentive or if workflow code is generated through poorly configured automation, weaponized code can be introduced into trusted workflows.

Additionally, pinned actions can still pull mutable dependencies internally. A pinned trusted-org/main-action@sha123 might still depend on something like minor-developer/helper-action@v1, which could later be compromised.

GitHub also expanded immutable releases support to prevent maintainers from force-pushing existing tags, which is exactly why only v0.35.0 (protected by immutable releases) survived the Trivy attack. But this protection remains opt-in rather than a default state.

Attack class 3: pull_request_target and secrets inheritance

The normal pull_request trigger spawns a runner that operates entirely inside the isolated context of a fork's pull request. The pull_request_target, on the other hand, grants external PRs access to repository secrets and a write-permissioned GITHUB_TOKEN, even when the PR comes from an untrusted fork.

Often, when teams need to automate pipeline activities such as labeling, sorting PRs, posting code coverage, running integration tests, or approving pull requests, the automation tools need write access, so they request the pull_request_target permission. But this tells GitHub to run the pipeline inside the context of the base repository. So, untrusted fork-controlled code can end up executing inside a privileged runner context. When analyzing major open-source infrastructure, Sysdig threat research highlighted that Workflows triggered by pull_request_target have access to all repository secrets, and the workflow's GITHUB_TOKEN is granted read and write permissions by default.

This was similar to the case of @tanstack. A malicious fork PR abused a pull_request_target workflow and eventually gained execution inside a release context that had access to an OpenID Connect (OIDC) token. The attackers then minted an OIDC-backed npm publish token. Switching to OIDC was not enough to eliminate this attack vector. It only changed the type of credentials the attacker stole. Appwrite's technical analysis of the incident highlights why modern identity federation does not stop breaches made possible by infrastructure design. The attacker did not need a classic npm token because code execution inside the release workflow was enough to mint an OIDC-backed publish token.

GitHub later hardened pull_request_target behavior in late 2025 by forcing workflows triggered through it to always use the workflow definition from the default branch. However, security research throughout 2026 shows that vulnerable configurations persist across thousands of legacy repositories that have not updated their older workflow designs.

To avoid breaches from this vector, teams should audit both legacy and modern workflows to ensure they do not check out untrusted code from forks in privileged contexts and instead use the pull_request trigger. If teams must automate activities around external PRs, it is safer to split the logic into two separate pipelines using workflow_run.

The table below gives you a scannable blueprint for workflow trigger types.

Trigger TypeProvides Repository Secrets Access?GITHUB_TOKEN Permission ContextRecommended Use Case

pull_request

No (Isolated in fork context)

Read-only in forked PRs

Running untrusted linters, tests, and builds from external contributors or forks safely.

pull_request_target

YES (Runs in base repository context)

Inherits base repository permissions

Highly restricted. Labeling, sorting, or triaging PR metadata. Never check out or execute untrusted code here.

workflow_run

YES (Triggered after a primary run finishes)

Depends on repository defaults

Post-processing validation. Taking safe test results from an isolated PR run and uploading comments or updating status checks.

push

YES (Runs strictly on internal branches)

Depends on repository defaults

Standard CI/CD automation, internal branch deployments, and running official release tags.

The truth is that the underlying issue spans beyond single triggers. By default, GitHub's secret architecture is binary and scoped broadly at the repository or organization level. If a workflow file is granted access to a secret, every single step and dependency inside that file can read it. This becomes even more problematic when teams use reusable workflows. A pipeline that calls one passes all credentials down the chain using the secrets: inherit flag. Even GitHub's 2026 security roadmap acknowledges this secret scoping flaw, noting that secrets become difficult to use safely, particularly with reusable workflows where credentials flow broadly by default.

Attack class 4: Cache poisoning and artifact exposure

A workflow that appears to have no dangerous permissions can still compromise a privileged workflow through the shared Actions cache. Security researcher Adnan Khan formalized this cache-poisoning attack vector in his work, demonstrating how a low-impact execution point could escalate into a full supply-chain compromise.

Once an unprivileged runner containing malicious code is allowed to write into the shared Actions cache using the same cache key as a privileged workflow, it poisons the cache boundary. When a legitimate maintainer later pushes a clean commit to the default branch, the release workflow restores the cached packages to accelerate its build process. Because the cache key matches, the runner blindly extracts the attacker’s poisoned binaries directly into its workspace. This is how the TanStack attackers poisoned the pnpm store cache, causing malicious code to execute in the privileged runner context.

Artifact exposure is another dangerous vector because artifacts are essentially uploaded workflow outputs, such as logs, debug bundles, test reports, build outputs, or crash dumps, that can accidentally contain secrets. GitHub allows these artifacts to be uploaded and later downloaded across both private and public repositories. This means that if artifacts contain sensitive material like .env files, cloud credentials, auth headers, or npm tokens, anyone with repository read access may be able to download them.

There are a few mitigation strategies teams can follow to reduce the risk of this attack class.

Firstly, separate cache scopes using cache-key namespace isolation. Ensure privileged pipelines use isolated cache prefixes that fork-triggered or untrusted workflows cannot replicate or overwrite.

Secondly, restrict artifact download access on workflows running in privileged contexts to prevent low-privilege users from scraping secrets from uploaded logs or generated assets.

Additionally, attackers cannot poison caches unless they first find a way to execute untrusted instructions inside the workflow. GitHub now ships CodeQL queries that detect unsafe cache usage patterns and instances where workflows execute untrusted input, such as github.event.pull_request.title, directly inside shell commands attackers can hijack.

The caveat, however, is that CodeQL detects these patterns; it does not prevent cache poisoning from happening.

Each of these attack classes exposes structural weaknesses in how GitHub Actions handles trust boundaries, secrets, and workflow execution protections. GitHub itself has acknowledged that parts of the current model require stronger secret scoping and isolation controls. While many of these improvements are still evolving, the available mitigations can help reduce the likelihood of compromise. The inclusion of an external secrets manager can also improve your workflow’s survivability during a compromise.

What an external secrets manager adds

The quote from GitHub Actions’ 2026 security roadmap on how secrets are scoped and reused across workflows exposes a fundamental reality. Inside GitHub’s native storage model, secret management is a blunt instrument. When you assign a secret to a repository, it becomes accessible to any workflow file, branch, or third-party dependency that executes within that repository.

Yes, GitHub has certain security measures in place, such as SHA pinning, OIDC, and read-only tokens, but these mainly help secure major cloud infrastructure providers such as AWS, GCP, and Azure. The runner uses an ephemeral identity token to assume an IAM role directly within your cloud workspace, only for the duration of the Job. But what about SaaS platforms and internal developer infrastructure credentials such as Slack or Teams webhooks, Docker Hub tokens, legacy npm or PyPI registry keys, internal API keys, database connection strings, and SSH deploy keys that lack OIDC support? These credentials are stored directly in GitHub repositories and usually have no rotation schedule, no per-job scoping, or unified audit visibility from GitHub itself. They remain valid long after the Job finishes, and when a supply chain attack compromises a runner, there is limited visibility into which Jobs accessed which secrets during runtime.

To address this broad vulnerability surface, teams can decouple credential storage from GitHub using an external runtime fetch pattern. At workflow start, the Job fetches credentials from a central secrets manager and injects them as environment variables for the duration of the run. While the credentials still exist in the runner's memory during execution, this pattern allows teams to centralize emergency rotation and revocation during incidents like the Trivy or Checkmarx attacks. Secrets can also be scoped more tightly to Jobs to better control access across workflows and runtime contexts. Teams can configure policies that state: if the incoming OIDC token says this is a test.yml workflow, only inject the staging Slack webhook. It also makes post-breach forensics easier because audit trails can map explicit secret fetches to specific runtime identities. Doppler is an example of a secrets manager that can provide this control surface within workflows.

Doppler also prevents the storage of hard-coded DOPPLER_TOKEN values on GitHub through the use of dopplerhq/secrets-fetch-action@v2.0. By setting auth-method: oidc, each workflow run receives a fresh, short-lived token from GitHub’s OIDC provider. Doppler authenticates against the token by validating the repository ID, workflow name, Git ref, and workflow scope before passing credentials to the runner.

Think of the various patterns of securing workflow secrets like this:

Pattern 1: You store long-lived secrets in GitHub repository or organization settings and reference them through ${{ secrets.MY_SECRET }}. The only thing achieved here is preventing hardcoded secrets; secrets remain broadly scoped. This means rotation remains manual, any privileged workflow step or dependency can access them, and there is no deep runtime visibility into how they are used.

Pattern 2: You use OIDC federation for AWS credentials using the aws-actions/configure-aws-credentials action. This eliminates static cloud API keys, manual cloud credential rotation, and the risk of long-lived cloud credential leakage. But for non-cloud shared secrets, you still have the same visibility and handling problems as Pattern 1.

Pattern 3: You use Doppler runtime fetch with OIDC authentication. Secrets are fetched dynamically during workflow execution, enabling teams to gain centralized control over secrets. This eliminates repository-scattered static secrets across multiple repositories, manual per-repository secret rotation, and the lack of centralized audit visibility. This also allows teams to scope secret injection more tightly to specific workflows, Jobs, and runtime contexts.

These patterns do not completely fix the underlying structural flaws, but they act as layered risk mitigations that strengthen your GitHub Actions security posture.

GitHub Actions security checklist

Use the checklist below to audit your GitHub Actions environment against the four attack classes covered in this article. The controls are divided into workflow hardening and secrets management to ensure you cover the most important security patterns.

Workflow hardening:

  • Default GITHUB_TOKEN permissions are set to read-only at the organization level.
  • No pull_request_target workflow checks out github.event.pull_request.head.sha.
  • Every third-party action is pinned to a full commit SHA, not a version tag.
  • CodeQL scanning for GitHub Actions workflows is enabled on the repository.
  • No workflow step interpolates a secret value directly into a shell command.
  • Artifact download access is restricted for any workflow running in a privileged context.

Secrets management:

  • Static cloud-provider credentials have been replaced with OIDC federation.
  • Remaining shared credentials (Slack, Docker Hub, npm registry tokens, internal APIs) are stored in a centralized secrets manager rather than in GitHub repository or organization settings.
  • Rotation is managed from a single control plane and propagates automatically to all consuming workflows.
  • Every secret access is logged with a timestamp and a job identity.
  • Reusable workflows use explicit secrets inheritance, not implicit passthrough.

GitHub Actions security is ultimately a trust-boundary problem. The safest workflows are the ones that minimize privilege, isolate execution paths, and reduce the lifetime and scope of exposed credentials. A secrets manager like Doppler helps reinforce these controls. Get started for free at doppler.com.

Enjoying this content? Stay up to date and get our latest blogs, guides, and tutorials.

Related Content

Explore More