Skip to main content

Beyond the OIDC Silver Bullet: Why "Keyless" GitHub Actions Aren't Enough

· 19 min read
Austen Stone
Senior Solutions Engineer @ GitHub

A silver bullet shattering against a layered glass shield

If you've modernized a CI/CD pipeline in the last couple of years, you've heard the gospel of OpenID Connect: stop hardcoding long-lived AWS IAM keys in your repository secrets. Your workflow requests a short-lived token, the cloud verifies the claims, you deploy. It's a real upgrade. It's also where most teams stop thinking, and that's the problem.

OIDC solves one thing: credential persistence. No more static keys sitting in a secret store waiting to leak. But it does nothing about the actual job of a pipeline, which is to execute arbitrary code on your behalf. Your keys are only one of the things that code can abuse, and most of the attacks landing in the wild right now never touch your OIDC token at all. They hijack the pipeline before identity is even in the picture.

So treat keyless auth as table stakes, not a finish line. Here's everything it leaves on the table.

The Workload Identity Paradox

Malicious code inheriting a key badge and bridging into a private VPC

When you tie identity to the pipeline environment via OIDC, you're making one assumption: the code running in the pipeline is trustworthy.

It isn't. Pipelines exist to execute arbitrary code automatically. That's their whole job. The moment a developer account is compromised, or a malicious package slips into your tree during npm install, that script runs on a runner with the exact same trust as your real deploy step.

Now broker a network tunnel from that runner into your private subnet with Tailscale or Cloudflare Access and you've handed the attacker an authenticated bridge straight into your infra. Identity is tied to the environment, so the malicious code inherits the keys to the kingdom. This is exactly the failure mode behind real-world incidents like the tj-actions/changed-files compromise and the Codecov bash uploader breach.

Five Ways In That Have Nothing To Do With Your Keys

Before you ever broker a token, an attacker has cheaper paths into your runner. None of these care whether you went keyless. And they don't stay contained: the 2026 wave of attacks, Megalodon, GhostAction, the Shai-Hulud worm, is self-propagating. A pipeline dumps its secrets, those secrets backdoor a package, the package compromises more developer machines, and those machines harvest more credentials. Your pipeline isn't just a target. It's a node in a worm, and every secret it leaks is a seed for the next hop. Containing the blast radius isn't only about protecting yourself; it's about refusing to be a vector.

Stolen credentials push workflows straight to main

This is the single biggest vector in the wild right now, and it doesn't involve a fork or a dependency at all. An attacker gets a compromised PAT or gh CLI OAuth token (usually harvested from a previous supply-chain hop), commits a malicious workflow directly to main across every repo that credential can write to, triggers it to dump every secret, then deletes the runs with the same token so there's no public log. The root cause is structural: repository write access automatically grants secret access, with no second factor. The defense is to decouple the two: gate secret-bearing jobs behind a GitHub Environment with required reviewers, or pull them from an external secret vault via OIDC, so that write access alone can't silently exfiltrate anything.

Mutable tags get re-resolved on every run

uses: owner/action@v2 looks like a pinned dependency. It isn't. It's a declaration the runner re-resolves on every single execution against a mutable git tag. Actions is effectively a package manager with no lockfile, no integrity hashes, and no transitive visibility, where the uses: line is re-resolved on every run. Compromise a maintainer's token, repoint v2 to an imposter commit, and every consumer pulls the malicious code on their next run with no PR, no diff, no warning. That's the entire mechanism behind the tj-actions and actions-cool/issues-helper mass tag rewrites. Pinning to a full commit SHA is the only thing that closes it, because a SHA is content-addressed and can't be repointed underneath you.

Cache poisoning crosses job boundaries

The Actions cache is shared across jobs in a repo. That means a low-privilege job, the one running untrusted PR code or third-party dependencies, can write a poisoned entry that a later privileged job restores and trusts. The cache is a covert channel straight through any isolation you think you built. The TanStack compromise used exactly this as its escalation vector. Treat restored cache as untrusted input, scope reads and writes as discrete permissions so a sensitive deploy can be granted neither, and keep caching off your privileged paths entirely.

pull_request_target runs attacker code with your secrets

A pull_request_target workflow runs in the context of your base repo, with secrets in scope, but it's triggered by forked PRs from anyone. The trigger itself isn't the bug. The bug is pull_request_target plus checking out the PR's head or merge ref plus executing it, which is when a stranger's code runs with your secrets. It's the classic pwn request. The same shape shows up on other privileged triggers, and they're easier to overlook: workflow_run hands you artifacts a pull_request run uploaded, and treating that artifact's contents as trusted (extracting it into your workdir, reading a "PR number" an attacker actually controls) is the same vulnerability one step removed. issue_comment and label-driven ops add a time-of-check/time-of-use twist: you approve a PR once, but the checkout runs on every comment, so the attacker pushes a fresh malicious commit after approval. Never check out and run untrusted PR code in a privileged trigger, never trust an artifact from a less-privileged run, and default every workflow to permissions: {} so nothing is in scope unless you explicitly grant it.

Dependency confusion resolves your private action to a public impostor

This one is subtler because the workflow file never changes. An action or a build step references a dependency it assumes lives in your private registry. The private copy goes away, the name fails to resolve internally, and resolution silently falls back to a public registry where it was never published. An attacker who registered that name in the public namespace gets their code executed instead. It's the dependency confusion attack Alex Birsan used to breach Apple, Microsoft, and dozens of others, and the same fallback exists anywhere you mix public and private resolution, including self-hosted GitHub setups that resolve missing actions from github.com. Pin the source, not just the version: be explicit about which registry or host a dependency comes from, and never let a private name silently resolve to a public one.

Limiting the Blast Radius

Stop building a taller wall around the credentials. Isolate the execution environment instead. Five moves.

A runner contained inside concentric defensive rings with four shields

1. The "Build vs. Deploy" airgap

Never request your OIDC token in the same job that fetches third-party dependencies or runs tests. Split the workflow into two isolated jobs:

One catch: an airgap built only on job boundaries leaks through the shared cache. If the dirty room can write a cache entry the vault restores, you've reconnected the two rooms through the back door. Keep caching out of the deploy job, and gate that job behind a GitHub Environment with required reviewers, scoping your IAM trust policy to that environment's sub claim.

And make that claim hard to spoof, because OIDC is only as strong as the trust condition you write against it. Prefer an immutable identifier like repository_id over a mutable repo:owner/name string, since repos get renamed and re-created but IDs don't, and reach for job_workflow_ref to pin the exact workflow file when you're federating reusable workflows. GitHub recently shipped immutable subject claims to close one spoofing gap, but the cloud side still has to ask for the strict claim, and note that aud is attacker-influenced and unvalidated by GitHub, so your provider has to check it. A wildcard sub match is a keyless credential anyone in your org can mint.

2. Egress filtering at the runner level

Once an attacker has code execution, the next step is exfiltrating the token or pulling a second-stage payload. Secret masking won't save you here: the runner redacts known secret values from logs, but a hijacked step can base64-encode a token to slip past the masker and POST it straight out. Masking stops accidental logging, not deliberate exfiltration. So kill it at the kernel. Tools like StepSecurity Harden-Runner use eBPF to enforce network egress policies on the runner. Allowlist github.com, your package registries, and your target cloud endpoints. A hijacked script that tries to curl a random IP dies on the spot, and you get an audit trail of what it tried to reach.

On GitHub-hosted runners the platform-level version of this is GitHub's native egress firewall: a Layer 7 allowlist that runs outside the runner VM, so it holds even if an attacker gets root inside the job. Until it's GA you can approximate it by pulling the allowed-domain list from the meta API and refreshing your firewall on a schedule. Either way the principle is the same: network controls belong at the network layer, not bolted onto a runner an attacker may already control.

3. Absolute dependency pinning

Now make the SHA discipline from above operational and complete. Pin every Action to a full commit SHA, never a tag like @v2. Strip ^ and ~ from package.json and commit a lockfile, then use npm ci for reproducible installs. Turn on Dependabot to bump those SHAs safely, since the moment you pin to a commit you've opted out of automatic updates and Dependabot is what keeps the pin current (it understands SHA-pinned actions and rewrites the SHA with a version comment). Pair it with Dependabot alerts, which cross-reference the GitHub Advisory Database so a dependency with a known published vulnerability gets flagged and auto-PR'd instead of sitting silently in your tree. Then require CODEOWNERS approval plus a branch protection rule on anything touching .github/, so Dependabot can open the PR but a human still has to approve the change and it never becomes its own auto-merge backdoor. Pin your runner's tools too with actions/setup-node and consider allowed_actions policies at the org level.

One honest limit: pinning your top-level uses: to a SHA does nothing for the transitive actions those actions call, which still resolve their own tags at runtime. You can't hand-pin that tree today, which is exactly why native workflow dependency locking is on GitHub's roadmap. Until it lands, prefer actions with shallow dependency trees and pin what you can.

Two sharp edges even a SHA pin doesn't sand down. First, imposter commits: you can reference a commit that lives in a repo's fork network but was never in the parent repo itself, so the SHA looks legitimate while pointing at attacker code that no maintainer ever merged. This was the trick behind the Trivy action compromise, and the real fix is a commit reachability check, confirming the SHA is actually reachable from the repo's own branches, not just resolvable. You can scan for these today with a dedicated imposter-commit detector before you trust a pin. Second, repo-jacking: rename owner/action, GitHub redirects the old name seamlessly, an attacker re-registers the vacated name, and consumers who never updated silently pull the new malicious repo. A SHA pin tied only to owner/repo doesn't save you here; durable protection means pinning to the immutable repository ID, which doesn't survive a takeover. And worth knowing: Git's SHA-1 is cryptographically weakening and the ecosystem is migrating to SHA-256, so a pin is strong today but not eternal.

The cheapest pin of all is the dependency you never take. Actions inherited npm's culture, not Go's: where the Go community leans on a fat standard library and treats every third-party import as a liability, the Actions ecosystem encourages reaching for a marketplace action for everything. That breadth is exactly why a single compromised action immediately compromises every consumer. Audit your uses: list the way you'd audit package.json, drop the actions you could replace with a few lines of shell, and you shrink the attack surface before any pinning even matters.

4. Granular network scoping

If the OIDC token brokers a Zero Trust connection, enforce least privilege on the tunnel itself. Don't route to the whole VPC. Route to the single IP and port the deploy actually needs, scoped with a security group or Tailscale ACL tag. The token should be a key to one door, not the building.

The same discipline applies to how the runner reaches your network in the first place. Hosted runners get a few sanctioned bridges into private resources, in increasing order of real isolation: static IP allowlisting on larger runners (easy, but it's still public-internet IP trust and a static target worth questioning), an API gateway fronted by OIDC, and Azure VNet injection, where the runner pulls an IP from your subnet and you own the NSGs. VNet injection is the most complete, but mind the edges: each concurrent job consumes one IP, so size the subnet (a /27 minimum) or jobs stop queueing, and a firewall doing TLS inspection breaks runners that won't trust your internal CA. The throughline across all three is to move the trust boundary off public-IP reputation and onto identity wherever you can.

5. Lint the workflows themselves

Most of the mistakes above are detectable statically. Run zizmor over your .github/workflows to catch dangerous triggers, missing permissions: blocks, unpinned actions, and template-injection sinks before they ship, and wire it into CI so a risky pattern fails the PR. GitHub's own CodeQL Actions analysis covers similar ground and is free for public repos. Neither one catches a malicious dependency, that's what the pinning and isolation above are for, and Dependabot alerts handle the known-vulnerable ones, but they're the cheapest way to stop your own workflows from being the hole.

The Bottom Line

A verified token passing through multiple inspection gates, defense in depth

OIDC for CI/CD is non-negotiable, but it's only the authentication layer, and most real-world attacks never reach it. They hijack the runner first through a stolen write token, a mutable tag, a poisoned cache, or a fork PR running with your secrets, and then they worm onward using whatever they steal. You can't scan your way out of this either: the minimal backdoor is swapping one trusted SHA for a malicious one, which defeats any string or pattern matching and turns detection into cat-and-mouse. The durable fixes are structural, not reactive. Without SHA pinning, cache discipline, locked-down triggers, write access decoupled from secrets, job isolation, egress filtering, and real observability into what your runners are doing, you haven't eliminated the risk. You've moved it deeper into the pipeline where it's harder to see.

Trust the identity. Verify the execution. For the full checklist, GitHub's security hardening guide and the OpenSSF Scorecard are the right places to start.