DEV Community

Manu Shukla
Manu Shukla

Posted on • Originally published at ecorpit.com

CI secrets are readable from runner memory: the 2026 credential isolation architecture for GitHub Actions

CI secrets are readable from runner memory: the 2026 credential isolation architecture for GitHub Actions

Summary. In March 2026, attackers compromised 75 of 76 trivy-action version tags by force-push and exfiltrated secrets from every pipeline that ran a Trivy scan, and the stolen credentials cascaded into PyPI compromises including LiteLLM. In the same month, malicious Axios versions 1.14.1 and 0.30.4 were live for roughly 3 hours, which was enough to hit pipelines that resolve dependencies at build time. On 4 August 2026, the second-stage payload in the keyv npm compromise was reported to target GitHub and npm tokens, cloud credentials, Vault tokens, Kubernetes service account tokens and GitHub Actions runner memory. GitHub has shipped real controls since February 2023, when the default GITHUB_TOKEN permission became read-only, through SHA pinning enforcement in August 2025 and immutable releases in October 2025. None of them stop a poisoned action from reading a secret the job already holds. With IBM putting the 2026 average breach cost at $4.99 million, the design question is not how to hide a secret from a log. It is how few secrets a compromised job can reach.

Masking is a log-redaction feature. It is not an access control. This article is about the architecture that is.

Why masking is the wrong mental model

GitHub redacts registered secret values from workflow logs. That protects against accidental disclosure, which is a real class of bug and worth having. It does nothing about code that runs inside the job.

A step that executes attacker-controlled code inherits the job's environment: the environment variables you set, the files on disk, the tokens actions/checkout persisted, and the memory of every process in that job. The Snyk analysis of the keyv compromise describes exactly this capability set in the second stage, including reading GitHub Actions runner memory. Redaction runs on the log stream after the fact. The payload runs before it.

Rami McCarthy and Shay Berkovich of Wiz, who maintain one of the more detailed hardening guides for the platform, put the pattern this way in their April 2026 update: "Credential theft was the common thread in the March 2026 incidents: stolen secrets enabled lateral movement from compromised workflows to package registries."

There is a second fact most teams have not internalised. GitHub's own documentation states that any user with write access to your repository has read access to all secrets configured in that repository. Your secret store is therefore only as tight as your write-access list.

Where a credential lives What can read it The control that actually helps
Repository secret in job env Any code in that job, including third-party actions Scope to the step, not the job or workflow
${{ toJson(secrets) }} Every secret in the repository, in one variable Never use it; reference secrets by name
secrets: inherit on a reusable workflow The callee gets everything the caller had Declare each secret explicitly
.git/config after actions/checkout Later steps, and anything that uploads artifacts persist-credentials: false
GITHUB_TOKEN Every step in the job permissions: {} at workflow level, then grant per job
Cloud access key stored as a secret The whole job, and anything it runs Replace with OIDC and a trust policy
Runner process memory Code running in the same job Do not put the credential in the job at all

The last row is the one that matters. Every other control reduces exposure. Only that one removes it.

The isolation architecture in one page

The design rule is simple to state and awkward to retrofit: a job should hold the smallest credential that can do its work, for the shortest time, and no job that runs untrusted input should hold a credential at all.

That resolves into four mechanisms, and the choice between them is not stylistic.

Credential type Use this What an attacker gets if the job is poisoned
Cloud access (AWS, Azure, GCP) OIDC with a trust policy scoped to repo, branch and environment A short-lived token bound to that workflow identity, expiring in minutes
GitHub API access GITHUB_TOKEN with per-job permissions Exactly the scopes you granted, for the life of the job
Release and deploy operations A GitHub App installation token, minted in a separate privileged job Nothing, if the App token never enters the build job
Third-party API keys, database URLs, signing keys A repository or environment secret, scoped to a step The one secret you exposed to that step

The order is deliberate. Reach for a stored secret only when nothing above it applies.

OIDC first. GitHub can mint a short-lived, identity-bound token for each workflow run that a cloud provider verifies against a trust policy. There is no long-lived key to steal, and the trust policy can require the repository, the ref and the environment, so a token minted from a fork or a feature branch is refused.

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: aws-actions/configure-aws-credentials@<full-commit-sha>
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy
          aws-region: ap-south-1
Enter fullscreen mode Exit fullscreen mode

permissions: {} at the workflow level. Setting it empty at the top forces every job to declare what it needs. Organisations created before February 2023 inherited read-write defaults, and plenty of them still carry that setting.

permissions: {}

jobs:
  build:
    permissions:
      contents: read
    runs-on: ubuntu-latest
Enter fullscreen mode Exit fullscreen mode

GitHub Apps for the high-value operations. Workflows mix code and secrets in the same attack surface, so a compromised workflow is a compromised secret. Moving release and deploy credentials into a GitHub App isolates them in a separate trust boundary. The practical shape is a two-job workflow: a build job with no privileged credential, and a separate job that mints an installation token and consumes the build artifact. If the App token never enters the job that runs third-party code, poisoning the build gets an attacker an artifact and nothing else.

Environment-level secrets with required reviewers. Environment secrets are only available to jobs that reference the environment, and you can require approval from named reviewers before the job runs. That converts a secret from something any merged code can read into something a human approves each time.

Stop the secret from entering the job in the first place

Three antipatterns cause most over-exposure, and all three are one-line fixes.

Do not hand a step the whole secrets context:

# Do not do this. Every repository secret is now in one variable.
env:
  SECRETS: ${{ toJson(secrets) }}
Enter fullscreen mode Exit fullscreen mode

Do not use secrets: inherit on reusable workflows. Declare what the callee needs:

jobs:
  publish:
    uses: ./.github/workflows/publish.yml
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

And turn off credential persistence on checkout unless you need it, because actions/checkout writes a credential into .git/config by default and later steps have been known to publish it inside an uploaded artifact:

- uses: actions/checkout@<full-commit-sha>
  with:
    persist-credentials: false
Enter fullscreen mode Exit fullscreen mode

Pass secrets at the step level, in env, only for the step that needs them. A job-level env block hands the value to every step, including the third-party action three steps later.

The trigger and input problems that turn a PR into code execution

Credential isolation only helps if you also stop attacker-controlled input from reaching a privileged execution path. This class of bug is called poisoned pipeline execution, and the main entry points are well known.

High-privilege triggers. pull_request_target and workflow_run execute in the context of the base repository, so they have access to repository secrets. Since November 2025, pull_request_target always uses the default branch as its workflow source, which closes exploitation of outdated vulnerable workflows on other branches. The trigger is still dangerous: if the workflow checks out and runs code from the pull request head, an attacker executes arbitrary code with access to your secrets. Astral's engineering team, quoted in the Wiz guide, is blunt about it: "these triggers are almost impossible to use securely."

Template injection in run: blocks. Interpolating ${{ github.event.issue.title }}, a branch name or file contents directly into a shell command is command injection with extra steps. Pass values through defined inputs or environment variables and quote them, rather than letting the expression engine paste text into your shell.

GITHUB_ENV and GITHUB_PATH. Both influence subsequent steps: one sets environment variables, the other modifies the system path. An attacker who can write to either can introduce a malicious binary or set something like LD_PRELOAD. Treat any write to those files in a step that has seen untrusted content as arbitrary code execution in the next step.

Tools that execute during configuration. Linters, test runners, build systems and scanners read files from the repository, and several of them execute code during configuration or initialisation. A config file in a pull request is executable input.

Supply chain controls that reduce how often this comes up

Isolation limits the damage. These controls reduce the frequency.

  1. Pin third-party actions to a full commit SHA, not a tag. The trivy-action compromise worked by force-pushing tags, so a tag reference silently moved to attacker code. Only a SHA guarantees the same code runs every time. GitHub's Actions policy has supported SHA pinning enforcement since August 2025, which fails workflows using unpinned actions rather than warning about them, and action blocking with a ! prefix so you can shut off a specific action during an incident.
  2. Account for transitive risk. Pinning your action does not pin the actions it calls. An action you SHA-pinned that depends on a tag-referenced action leaves you exposed.
  3. Adopt a cooldown before updating actions. Wiz cites a 7 to 14 day delay before adopting new versions as catching 80% to 90% of supply chain attacks, because detection windows are typically under a week. pinact --min-age 7 and Renovate's minimumReleaseAge enforce it automatically. The same reasoning applies to your package manager, which we covered in npm provenance and cooldown policy.
  4. Lint workflows in CI. zizmor catches unpinned actions, template injection and dangerous triggers before they ship. Running it as a required check costs one job and removes a whole category of review burden.
  5. Restrict which actions can run at all. Limit workflows to GitHub-created and Marketplace-verified actions, plus an explicit allowlist.
  6. Enable immutable releases if you publish actions. Generally available since 28 October 2025, it prevents release assets and Git tags being modified after publication, which is precisely the tag-rewriting attack used against trivy-action.

Runners: ephemeral by default, or treat them as production

GitHub-hosted runners are ephemeral and sandboxed, which suits most workloads. Self-hosted runners execute jobs on machines you manage and are non-ephemeral by default, so the environment persists between jobs. A compromised workflow can install background processes, tamper with the environment or leave persistent malware for the next job to meet.

Two rules follow. Self-hosted runners should not be used with public repositories, because that exposes your infrastructure to workflows from forks and pull requests. And where self-hosted runners are used, isolate them by trust level with runner groups so public repositories never share infrastructure with private ones, and tear the machine down after each job.

Egress control is the other half. For self-hosted runners, run a default deny-all outbound policy with explicit allowlists for artifact repositories, package registries and required APIs. For GitHub-hosted runners, monitor egress to detect unexpected outbound connections. Note the limit of that control: the keyv loader fetched its runtime from GitHub itself, so a domain allowlist that trusts GitHub would not have fired. Egress monitoring narrows the exit, it does not seal it. Keeping runner versions current matters too, which we covered in GitHub Actions self-hosted runner version enforcement.

What GitHub is shipping next

GitHub's 2026 Actions security roadmap includes three items that make the manual work above enforceable at scale:

  • A dependencies: section, effectively a workflow lockfile that pins direct and transitive action dependencies by commit SHA, in the spirit of go.sum.
  • Workflow execution protections, centralised rulesets controlling who can trigger workflows and which events are permitted.
  • Evaluate mode, which tests a new policy without enforcing it so you can see what would be blocked before rolling it out.

Evaluate mode is the one to plan around. Most organisations do not know how many of their workflows would break under strict policy, and that uncertainty is why the policy never ships.

Control Available since What it stops
Read-only default GITHUB_TOKEN February 2023 Workflows silently holding write access to the repository
SHA pinning enforcement August 2025 Tag-rewriting attacks like trivy-action
Immutable releases 28 October 2025 Modification of release assets and tags after publication
pull_request_target default-branch workflow source November 2025 Exploitation of vulnerable workflows on non-default branches
Workflow lockfile and execution protections On the 2026 roadmap Transitive action drift and unauthorised workflow triggers

A staged rollout that will not stall

Trying to fix everything at once is how this work dies in a backlog. Sequence it by blast radius.

Week one, organisation settings. Set default workflow permissions to read-only. Turn off "allow GitHub Actions to create and approve pull requests". Limit actions to verified and allowlisted sources. Restrict self-hosted runners to specific repositories through runner groups. None of this touches a workflow file.

Week two, the highest-value credential. Pick the one that would hurt most, usually the production deploy or the package publish token. Move it behind OIDC or a GitHub App, and split the workflow so the build job never holds it.

Week three, the exposure antipatterns. Grep for toJson(secrets), secrets: inherit, job-level env blocks carrying secrets, and actions/checkout without persist-credentials: false. These are mechanical fixes with low review cost.

Week four, the supply chain layer. SHA-pin third-party actions, add zizmor as a required check, and configure a cooldown in Renovate.

Ongoing. Audit high-privilege triggers whenever a workflow changes. pull_request_target and workflow_run deserve a named reviewer, not a rubber stamp.

The honest constraint is organisational, not technical. Every control above is a day of work and a week of arguing about which team owns it. Deciding the ownership first is what makes the rest go quickly.

India-specific considerations

For Indian product teams and GCCs, three points change the priority order.

Shared build infrastructure is common, and it concentrates risk. A single self-hosted runner pool serving several client engagements means one poisoned action reaches credentials belonging to more than one customer, which is a contractual problem as well as a security one. Runner groups separated by client, and ephemeral runners, are worth the extra spend for exactly that reason.

Client-owned cloud accounts complicate OIDC. When the AWS or Azure account belongs to the client and the repository belongs to you, the trust policy has to be agreed by two organisations, and that negotiation takes longer than the implementation. Start it early, and ask for a role scoped to your repository and branch rather than a long-lived access key emailed over.

Finally, credentials stolen from a runner routinely include database connection strings for systems holding personal data, which brings a CI incident inside the scope of India's Digital Personal Data Protection Act 2023 rather than leaving it an engineering matter. The data-mapping side of that is covered in our DPDP Act engineering playbook for Indian startups.

FAQ

Does GitHub secret masking protect my credentials?

Only in logs. Masking redacts registered secret values from workflow output, which prevents accidental disclosure. It does not stop code running inside the job from reading the same value out of the environment, the filesystem or process memory. Isolation and scoping are the controls that limit what a compromised job can reach.

Should I use OIDC or stored secrets for cloud access?

Use OIDC wherever the provider supports it. GitHub mints a short-lived, identity-bound token per workflow run that the cloud provider verifies against a trust policy scoped to your repository, ref and environment. There is no long-lived key to steal. Keep stored secrets for third-party APIs and databases that support nothing better.

What does a GitHub App give me that a secret does not?

Separation. A workflow mixes code and secrets in one attack surface, so a compromised workflow means compromised secrets. A GitHub App holds the credential in a different trust boundary, and if you mint the installation token in a separate job from the one running third-party code, poisoning the build reaches nothing privileged.

Why pin actions to a commit SHA instead of a tag?

Because tags move. The March 2026 trivy-action compromise worked by force-pushing 75 of 76 version tags, so every pipeline referencing a tag pulled attacker code. Only a full commit SHA guarantees the same code runs each time. GitHub's Actions policy has supported SHA pinning enforcement since August 2025.

Is pull_request_target safe to use now?

Safer, not safe. Since November 2025 it always uses the default branch as its workflow source, which stops attackers exploiting vulnerable workflows on other branches. If your workflow still checks out and runs code from the pull request head, an attacker executes arbitrary code with access to your repository secrets.

How long should an action cooldown be?

Wiz cites 7 to 14 days as catching roughly 80% to 90% of supply chain attacks, because most compromised releases are detected and pulled inside a week. Enforce it with pinact --min-age 7 or Renovate's minimumReleaseAge rather than relying on review discipline, and exclude security patches explicitly.

Can I use self-hosted runners with public repositories?

You should not. Self-hosted runners are non-ephemeral by default, so a job can leave background processes or modified state behind for the next job. A public repository exposes that machine to workflows from forks and pull requests, which means arbitrary code on infrastructure you own.

What is the single highest-value change to make first?

Set default workflow permissions to read-only at the organisation level, then move your production deploy or package publish credential behind OIDC or a GitHub App. The first takes minutes and touches no workflow file. The second removes the credential an attacker most wants from the job most likely to run untrusted code.

How eCorpIT can help

eCorpIT is a CMMI Level 5 and ISO 27001:2022 certified engineering organisation in Gurugram, and our platform teams treat CI credential architecture as part of release engineering rather than a separate audit exercise. That means splitting privileged operations out of build jobs, moving cloud access to OIDC with trust policies your client's security team will actually sign off, and adding workflow linting and cooldowns as required checks. See our CI/CD and internal developer platform service or the wider software supply chain security service, or talk to a senior engineer through /contact-us/.

References

  1. Rami McCarthy and Shay Berkovich, How to Harden GitHub Actions: An Updated Guide, Wiz, 15 April 2026.
  2. Shay Berkovich, Primer on GitHub Actions Security: Threat Model, Attacks and Defenses, Wiz, 14 April 2026.
  3. Wiz, TeamPCP trojanizes LiteLLM in continuation of campaign.
  4. Liran Tal and Lion Kontorer, Inside the keyv npm Compromise, Snyk, 4 August 2026.
  5. GitHub Docs, Security hardening for GitHub Actions.
  6. GitHub Docs, Using secrets in GitHub Actions.
  7. GitHub Docs, Controlling permissions for GITHUB_TOKEN.
  8. GitHub Docs, About creating GitHub Apps.
  9. GitHub Changelog, GitHub Actions policy now supports blocking and SHA pinning actions, 15 August 2025.
  10. GitHub Changelog, Immutable releases are now generally available, 28 October 2025.
  11. GitHub Changelog, Actions pull_request_target and environment branch protections changes, 7 November 2025.
  12. GitHub, What's coming to our GitHub Actions 2026 security roadmap.
  13. GitHub Changelog, Updating the default GITHUB_TOKEN permissions to read-only, 2 February 2023.
  14. Astral, Open source security at Astral.
  15. William Woodruff, zizmor: a static analysis tool for GitHub Actions.
  16. Shunsuke Suzuki, pinact.
  17. Help Net Security, Data breach cost 2026 averaged $4.99 million, AI attacks ran higher, 30 July 2026.

Last updated: 5 August 2026.

Top comments (0)