The Missing Authorization Layer in GitHub Actions

The Missing Authorization Layer in GitHub Actions
Christopher Poenaru
Christopher Poenaru Developers
9m read

In March 2025, attackers compromised tj-actions/changed-files, a GitHub Action used in over 23,000 repositories. A single malicious commit rewrote the action to scrape secrets out of the runner’s memory and print them, base64-encoded, into the build log. Any repository that ran the action during the exposure window risked dumping its workflow secrets into logs that, on public repos, anyone could read. CISA issued an advisory (CVE-2025-30066). The industry treated it as a wake-up call.

Then, in May 2026, the Megalodon campaign hit. Over 5,700 malicious commits landed in 5,500+ repositories in under six hours, this time by pushing attacker-authored workflow files directly into repos with weak branch protection. Those workflows harvested AWS keys, GCP tokens, SSH keys, and dozens of other secret types straight from the CI environment.

These two attacks had completely different entry points. One was a dependency you trusted turning hostile. The other was an attacker with push access crafting their own workflows. The outcome was the same either way.

There’s no runtime authorization in GitHub Actions. Secrets get scoped to a job ahead of time, and once the job starts, any code that runs can read them. No identity check, no context, no decision at the moment of access.

Both attacks are instances of the same shape: code runs in a job, the secrets are already there, and there’s nothing between the code and the credential. That shape shows up in three common forms.

The three pitfalls

1. Trusted code, untrusted code, same access

In a CI job, every line of code that runs gets the same access, whether you wrote it or not. The runtime doesn’t draw a line.

That code arrives in two ways. The obvious one is a uses: step, like a marketplace action such as tj-actions/changed-files. You pinned it to a tag, the maintainer’s account got compromised, the tag was moved to malicious code, and suddenly an action you trusted is reading your secrets. The less obvious one is your own dependencies: an npm install in a CI job runs every dependency’s install scripts on the runner, with whatever secrets that job holds. That’s the mechanism behind the Shai-Hulud npm worm, and it doesn’t require anyone to touch your repo at all.

The malicious code didn’t need authorization to read the secret. Being in the same job was enough.

2. Once configured, always allowed

A secret in repo or org settings is configured once and read forever. The decision about whether a workflow is allowed to use it was made when the secret was put there, and never again.

There’s no per-run audit trail either. You know a secret was available to a workflow. You don’t know whether it was used, what it accessed, or what it did with it. When an incident happens, you’re grepping CI logs and hoping.

3. secrets: inherit passes credentials without context

A reusable workflow is the same code running in different contexts: different repos calling it, different branches, different events. secrets: inherit forwards every repo and org secret to that workflow, identically, no matter which context invoked it.

jobs:
  deploy:
    uses: ./.github/workflows/deploy.yml
    secrets: inherit  # every repo + org secret, needed or not

On its own, secrets: inherit leaks nothing. The problem is when it meets pitfall #1: a single compromised step in a workflow that inherited everything now leaks your database password, your Stripe key, and your signing cert all at once. You can pass secrets individually, but it’s tedious and nothing lints the mistake.


All three pitfalls are the same gap in different forms: there’s no enforcement point between the code in a job and the credentials that job holds.

There are really two problems here. The first is whether untrusted code runs in your job at all. The second is what it can reach if it does.

You can spend forever on the first, and you should: pin every action to a SHA, vet your dependencies, keep branch protection tight. None of that work ever finishes, because the supply chain keeps producing new ways for hostile code to land in your runner. The realistic move is to assume some of it eventually will, and protect the credential instead of the perimeter. When the bad code runs, there should be nothing in the environment worth taking.

Putting an authorization point between code and credentials

Keycard puts an authorization decision at the moment of access. Every credential request is evaluated against the requester’s verifiable identity and the context they’re operating in, and a scoped credential is issued only if policy permits. An AI agent and a GitHub Actions workflow are both non-human workloads, so the model is the same for both.

Keycard speaks OIDC and OAuth 2.0, so workflows authenticate to it with standard protocols. Here’s how the pieces map to a GitHub Actions workflow:

  • Applications are workload identities. A workflow is an application: it presents GitHub’s OIDC token, and Keycard verifies that identity before issuing anything.
  • Resources are the things a workflow needs to reach: a Fly.io deploy token, a Pulumi token, a database credential. Each has its own provider and scope.
  • Policies are written in Cedar, evaluated on every request, and default-deny. An application can only reach the resources declared in its dependencies, and only if policy permits. Policy can gate on the claims inside the workload’s identity token. For GitHub Actions, those claims include the branch, the workflow file, the repo, and the actor.

How a GitHub Actions workflow gets a credential through Keycard: the workflow requests an OIDC token, gha-keycard-auth presents it to Keycard, Cedar policy evaluates the request against the token's claims, and Keycard issues a scoped credential for the protected resource.

What this looks like in practice

We use Keycard to secure our own CI/CD. Here’s a real workflow from our internal Fly.io gateway deployment:

name: Deploy Gateway

on:
  push:
    branches: [main]

permissions:
  contents: read   # starve the GITHUB_TOKEN down to read-only
  id-token: write  # required to mint a GitHub OIDC token

jobs:
  deploy:
    name: Deploy internal-fly-gateway
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      # All third-party actions are pinned to a full commit SHA, not a tag.
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: superfly/flyctl-actions/setup-flyctl@fc53c09e1bc3be6f54706524e3b82c4f462f77be # 1.5

      - name: Auth via Keycard
        uses: keycardai/gha-keycard-auth@88ea886f38c26e08de9b27e16530c2da2be94746 # v0.3.0
        with:
          zone-url: ${{ vars.KEYCARD_ZONE_URL }}
          credentials: |
            - resource: https://api.machines.dev/internal-fly-gateway
              type: env
              env-name: FLY_API_TOKEN

      - name: Deploy
        run: flyctl deploy --remote-only --config fly.toml

There’s no FLY_API_TOKEN in GitHub’s secret settings. There’s no secrets: inherit. The workflow does three things:

  1. Proves its identity. id-token: write lets the workflow mint a GitHub OIDC token unique to this run, carrying claims about the repo, the workflow file, and the ref. gha-keycard-auth sends that token to Keycard.

  2. Keycard verifies the identity, then evaluates policy. Keycard confirms the token is a genuine GitHub OIDC token for a registered application, then runs Cedar against the token’s claims. This is where issuer_claims comes in: it exposes the raw claims from the OIDC token, including GitHub’s ref, directly to policy. Here’s the actual rule gating our Fly deploy:

    permit (
      principal is Keycard::Application,
      action,
      resource is Keycard::Resource
    )
    when {
      principal.dependencies.contains(resource) &&
      resource has identifier &&
      resource.identifier like "https://api.machines.dev/*" &&
      context has actor_claims &&
      context.actor_claims has issuer_claims &&
      context.actor_claims.issuer_claims.contains(
        { "key": "ref", "value": "refs/heads/main" }
      )
    };

    Read it as: allow access to the Fly machines API when (a) the resource is declared as a dependency of the requesting application, and (b) the OIDC token’s ref claim is refs/heads/main. Cedar policies are default-deny, so anything that doesn’t satisfy both conditions never gets a permit. The first clause is a baseline check that this application is even allowed to consider this resource. The second is the runtime context check: the “deploy only from main” rule that used to be a deletable YAML if: condition is now an authorization decision the workflow can’t talk its way around.

    One caveat: gating on ref == refs/heads/main stops injection from pull requests, forks, and feature branches, but it doesn’t stop an attacker who can push a malicious workflow directly to main (the Megalodon pattern). That token’s ref is also refs/heads/main. Two controls close the gap: branch protection that forbids direct pushes to main, and gating on workflow_ref instead, which carries the specific workflow file path (…/.github/workflows/deploy.yml@refs/heads/main) and distinguishes your deploy workflow from an injected one on the same branch.

  3. Keycard issues a scoped credential. If policy permits, Keycard returns a Fly.io deploy token and the action exposes it as FLY_API_TOKEN. It exists only from this step onward, scoped to one resource.

Not every provider speaks OIDC. For ones that don’t, Keycard supports Vaulted Resources: the credential lives in Keycard’s vault, encrypted with a per-tenant customer-managed key, and gets handed to the workflow only after the same checks as any other resource. The credential underneath is static, but access still goes through the same per-request authorization, and every issuance is logged.

Fly.io has no RFC 8693 token-exchange endpoint, so the Fly API token in this workflow is a Vaulted Resource. For providers that do federate (AWS via OIDC, for example), Keycard mints a short-lived credential on the spot and no static secret exists anywhere.

What changed from the old setup

Before Keycard, this workflow used a static Fly.io API token stored as a GitHub repository secret. Every workflow in the repo could read it. The “only deploy from main” rule was a YAML if: condition that any workflow author could delete. The audit trail ended at “the secret was available.”

The shift: secrets aren’t available by default anymore. They’re earned on every run. The question moved from “is this secret configured in the repo?” to “does this workflow, with its verified identity, have a policy that permits this resource right now?”

So what does an attacker actually get if a compromised action or a poisoned dependency runs in this workflow? If it runs before the Keycard step, there’s nothing to take. The Fly token doesn’t exist yet. If it runs after, the credential in the environment is scoped to one resource: a Fly API token for one application. There’s no org-wide secret bundle to walk off with, and no other credential in the environment to pivot into. For providers that federate, the prize shrinks further. The credential is short-lived, so even its window of usefulness is bounded.

We also got real audit trails. Every issuance is logged in Keycard: which application requested it, which resource, which policy permitted it, and when. After an incident you’re querying a record, not reconstructing intent from CI logs.

A compromised action will still run. What this changes is what it can take when it does, because the credential isn’t sitting in the environment anymore.


Keycard puts an authorization decision between non-human workloads and the credentials they need, applying the same model whether the workload is an agent, a CI job, or a service. Learn more at keycard.ai or read the docs.

Last updated July 15, 2026

Have questions about agent security?

Ask our agent — it's a live Keycard-on-Keycard demo.