← Back to homeci-cd

Zero-Secret CI/CD: OIDC, Ephemeral Credentials, and Pipelines That Can't Leak What They Don't Have

← All writing

There's a specific line in almost every post-incident writeup involving CI/CD, and it reads something like: "the attacker obtained a long-lived access key stored as a pipeline secret." The Codecov bash uploader compromise in 2021 worked this way — a modified script exfiltrated environment variables, and those environment variables were full of AWS keys, tokens, and database credentials that customers had pasted into their CI config. The CircleCI breach in early 2023 forced thousands of teams to rotate every secret they had ever stored in the platform, because an attacker with access to CircleCI's systems potentially had access to all of them. The tj-actions/changed-files supply-chain attack in 2025 dumped CI runner memory into build logs specifically because that's where the secrets live.

The pattern is boring and it keeps working: CI systems are secret aggregation points. A single build platform holds deploy credentials for hundreds of production environments, and those credentials are usually static, usually over-privileged, and usually valid until someone remembers to rotate them — which is never, because rotation breaks builds and nobody gets promoted for rotating keys.

At Luminary we ship client work through pipelines every day, and our position is simple: the best secret management strategy is not having secrets. A pipeline can't leak an AWS access key it doesn't have. This post is the full picture of how we build that — OIDC federation to the cloud, hardened workflows, and the deployment and caching patterns that go with it.

Illustration: a bank vault standing open and empty while a guard shrugs

Why long-lived keys in CI are the problem

A static cloud credential in a CI secret store has four properties that make it a liability:

  1. It's valid at rest. An AWS_SECRET_ACCESS_KEY copied out of a runner, a log, or a compromised action works from anywhere, indefinitely, until revoked. There's no binding between the credential and the context it was meant for.
  2. It's invisible in transit. When that key is used from an attacker's laptop, CloudTrail shows a legitimate IAM user doing legitimate-looking API calls. Distinguishing "our pipeline deployed" from "someone with our pipeline's key deployed" requires forensics, not a dashboard.
  3. It accumulates privilege. Keys created for one deploy job get reused for the next project, and the next, and the IAM policy attached to them grows monotonically. Nobody trims permissions on a key that's working.
  4. It's exposed to your entire supply chain. Every third-party action, every npm postinstall script, every tool your build invokes runs in an environment where that secret is one env dump away. You're not trusting your team; you're trusting the transitive closure of your dependency graph.

The fix isn't better secret storage. Vaults, encrypted-at-rest secret stores, and masked logs all help, but they treat the symptom. The fix is credentials that are issued per job, scoped to the job's identity, and dead in an hour. That's what OIDC federation gives you.

How OIDC federation actually works

The mechanism is the same across GitHub Actions, GitLab CI, and every major cloud, so it's worth understanding once, properly.

Your CI platform runs an OpenID Connect identity provider. When a job starts, the platform can mint a short-lived JWT — an ID token — that describes exactly what is running: which repository, which branch or tag, which workflow file, which environment, whether it was triggered by a pull request. The token is signed by the platform's private key, and the platform publishes the corresponding public keys at a well-known URL.

For GitHub Actions, the issuer is https://token.actions.githubusercontent.com, and the token's claims look like this (abbreviated):

{
  "iss": "https://token.actions.githubusercontent.com",
  "aud": "sts.amazonaws.com",
  "sub": "repo:luminary-studio/client-platform:environment:production",
  "repository": "luminary-studio/client-platform",
  "repository_owner": "luminary-studio",
  "ref": "refs/heads/main",
  "workflow_ref": "luminary-studio/client-platform/.github/workflows/deploy.yml@refs/heads/main",
  "event_name": "push",
  "runner_environment": "github-hosted"
}

On the cloud side, you register the CI platform as a trusted identity provider and write a trust policy: "if a token is validly signed by GitHub's OIDC provider, and its claims match these conditions, exchange it for temporary credentials tied to this role." AWS calls this AssumeRoleWithWebIdentity against an IAM OIDC provider. GCP calls it Workload Identity Federation, where the token maps to a principal that impersonates a service account. Azure calls it a federated identity credential on an Entra ID app registration.

The security properties fall out of the mechanism:

  • No stored secret. The trust relationship is public-key cryptography plus claim matching. There is nothing in your CI secret store to steal.
  • Ephemeral by construction. The exchanged credentials are STS session credentials (or equivalent) that expire in minutes to an hour.
  • Identity-bound. The credentials only exist because that specific workflow on that specific branch in that specific repo asked for them. A fork, a different branch, or a different repo produces a token with different claims, and the exchange fails.

The sub claim is your firewall

The single most important detail in any OIDC setup is subject filtering. The sub claim encodes the job's identity, and your trust policy must match it as narrowly as possible:

Contextsub claim format
Push to a branchrepo:org/repo:ref:refs/heads/main
Tagrepo:org/repo:ref:refs/tags/v1.2.0
Pull requestrepo:org/repo:pull_request
Environment (recommended)repo:org/repo:environment:production

A trust policy that matches repo:org/* means any repository in your org — including the experimental one an intern created — can assume your production deploy role. A policy that matches repo:org/repo:* means any branch, including a PR branch containing code from an unreviewed contributor. Scope to the environment or the exact ref, always.

GitLab CI works the same way with different spelling: the job requests an ID token via the id_tokens: keyword (declaring the aud), and the claims include project_path, ref, ref_protected, and environment. Matching on ref_protected: "true" is GitLab's idiom for "only protected branches can deploy."

A complete worked example: GitHub Actions → AWS

Here's the full setup we use, end to end. First, the IAM side. You create an OIDC identity provider in the AWS account pointing at token.actions.githubusercontent.com with audience sts.amazonaws.com (one per account, reused by every role). Then a role with a trust policy like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:luminary-studio/client-platform:environment:production"
        }
      }
    }
  ]
}

Note the StringEquals on the exact sub, not a StringLike wildcard. This role can only be assumed by jobs running in the production environment of one specific repo. The role's permission policy is scoped to what the deploy actually does — push to one ECR repo, update one ECS service, invalidate one CloudFront distribution — not AdministratorAccess.

Then the workflow:

name: deploy-production

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write   # required to request the OIDC token
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-client-platform-prod-deploy
          role-session-name: gha-${{ github.run_id }}
          aws-region: ap-south-1

      - name: Deploy
        run: ./scripts/deploy.sh

That's the whole thing. No AWS_ACCESS_KEY_ID anywhere. The configure-aws-credentials action requests the ID token from the runner's token endpoint, exchanges it via STS, and exports session credentials into the job's environment. They expire in an hour by default. If an attacker exfiltrates them mid-job, they have a narrow window, a CloudTrail trail with a session name pointing at the exact run ID, and permissions limited to one service's deploy surface.

GCP and Azure are structurally identical: google-github-actions/auth with a Workload Identity Provider resource name and an attribute condition on assertion.repository, or azure/login with client-id/tenant-id/subscription-id and a federated credential whose subject matches the environment. Same claims, same filtering discipline, different console screens.

Environment protection: humans in the loop where it counts

The environment: production line above isn't decorative — it's what makes the sub claim say environment:production, and it's also where GitHub's environment protection rules attach. For production we configure:

  • Required reviewers. The job pauses until a designated human approves it. The OIDC token — and therefore the cloud credential — literally cannot exist until someone clicks approve. This is a much stronger control than a Slack message saying "deploying now."
  • Deployment branch policy. Only main (or only protected branches) may target the environment. Combined with branch protection on main itself — required PR reviews, required status checks — you get a chain: code can't reach main without review, and credentials can't be minted except from main.
  • Environment-scoped secrets for the few things that genuinely must remain secrets (a Formspree endpoint, a third-party API key with no OIDC story). These are only exposed to jobs that passed the protection rules, not to every workflow in the repo.

The mental model: branch protection governs what code exists, environment protection governs what code can touch production, and OIDC subject filtering makes the cloud enforce both.

Hardening the pipeline itself

OIDC removes the crown jewels, but the pipeline still runs code with real capabilities. The 2025 tj-actions incident is instructive: a compromised action's tag was rewritten to point at malicious code, so every workflow referencing @v35 picked up the payload automatically. The defenses are mostly configuration discipline:

Pin actions by full commit SHA. Tags are mutable pointers; SHAs are content-addressed. uses: some-org/some-action@v3 trusts whoever controls that tag forever. uses: some-org/some-action@<40-char-sha> # v3.1.4 trusts a specific, reviewed snapshot. Dependabot and Renovate both understand SHA-pinned actions and will raise PRs that bump the SHA and the version comment together, so this costs almost nothing to maintain.

Set permissions: explicitly, at the top level, to the minimum. The default GITHUB_TOKEN historically carried broad write access to the repo. Declare a restrictive default and escalate per job:

permissions:
  contents: read

jobs:
  release:
    permissions:
      contents: write        # to create the release
      id-token: write        # OIDC
      attestations: write    # provenance

A token that can only read contents can't push a backdoored commit, tamper with releases, or approve its own PRs, no matter what a compromised dependency tries. Also set the repository-level default to read-only in Settings → Actions so new workflows start safe.

Treat pull_request_target and workflow_run as loaded weapons. They run with secrets in the context of your base repo while potentially handling attacker-controlled input from forks. If you need them at all, never check out and execute the PR's code in that context. Static analyzers like zizmor and OpenSSF Scorecard will flag these patterns; we run them in CI on the workflows themselves.

Generate provenance for what you build. actions/attest-build-provenance produces a signed SLSA provenance attestation binding an artifact digest to the exact workflow, commit, and trigger that built it. Verification (gh attestation verify) then becomes a deploy-time gate: the artifact reaching production must be the artifact built by the expected workflow from the expected repo. This closes the "someone pushed a hand-built image to the registry" hole that OIDC alone doesn't.

Deployment design: making the safe path the fast path

Security hardening fails when it makes shipping slower, because people route around it. Our deployment shape is chosen so the hardened path is also the convenient one.

Trunk-based development with short-lived branches. Feature branches live days, not weeks; everything merges to main through a PR with CI as a required check. Long-lived release branches multiply the surface you have to protect and the trust policies you have to write. (Our own site runs a small variant — feature/* → dev → prod — because we want a permanently deployed integration environment, but both protected branches are PR-only and CI-gated.)

Preview environments per PR. Every pull request deploys to an isolated, ephemeral environment with its own scoped credentials — never production data, never the production role. This is where OIDC subject filtering earns its keep: the preview deploy role trusts pull_request subjects and can only touch preview infrastructure. Reviewers click a URL instead of pulling branches locally, and the production role remains unreachable from PR context by construction.

Progressive delivery for production. main deploys automatically to a canary slice or via a rolling update, gated on health checks — error rate, latency, and a handful of business metrics — with automated rollback on regression. The important property for this post: rollback must not require broader credentials than deploy. If rolling back means someone SSHes in with a personal admin key at 2 a.m., you've built a break-glass path that will quietly become the normal path.

Caching without poisoning yourself

Caches are the sleeper risk in hardened pipelines. GitHub Actions cache entries are scoped by branch with a specific inheritance rule: a branch can read caches created on its base branch and the default branch, but not caches from sibling branches or forks. That containment is the whole security model, and it means one thing above all: anything that can write the default branch's cache can inject code into every future build. So:

  • Never write caches from jobs that handle untrusted input. A pull_request job writing a cache that a main build later restores is a code-injection path if the cached content includes anything executable — compiled artifacts, node_modules, tool binaries. Restrict cache writes to trusted refs; let PRs be read-mostly.
  • Key caches on content hashes, exactly. key: deps-${{ hashFiles('package-lock.json') }} with no fuzzy restore-keys fallback for anything that gets executed. Broad restore-keys prefixes are convenient for build intermediates; they're inappropriate for dependency trees, because they let a stale-or-tampered near-match substitute for the real thing.
  • Cache dependencies, not credentials or config. It sounds obvious until you find a .npmrc with an auth token inside a cached directory. Audit what's actually in the tarball once in a while.
  • Prefer lockfile-verified installs over cached artifacts. npm ci against a committed lockfile with registry-verified integrity hashes is a stronger guarantee than restoring last week's node_modules blob. Cache the package manager's download cache (~/.npm), not the installed tree.

Monorepo pipelines: path filters and matrices

Most client platforms we build end up as monorepos — app, marketing site, infrastructure, shared packages. The pipeline design goals are: don't build what didn't change, and don't give one package's deploy job another package's credentials.

Path filters at the trigger level keep the obvious noise out:

on:
  push:
    branches: [main]
    paths:
      - "apps/api/**"
      - "packages/shared/**"

For anything less trivial, compute the change set in a job and fan out with a matrix:

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      apps: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
        id: filter
        with:
          filters: |
            api: ["apps/api/**", "packages/shared/**"]
            web: ["apps/web/**", "packages/shared/**"]

  build:
    needs: detect
    if: needs.detect.outputs.apps != '[]'
    strategy:
      matrix:
        app: ${{ fromJSON(needs.detect.outputs.apps) }}
    runs-on: ubuntu-latest
    steps:
      - run: make build-${{ matrix.app }}

Tools like Turborepo and Nx do the change detection with real dependency-graph awareness, which beats hand-maintained filter lists as the graph grows. The security corollary either way: one deploy role per app per environment, each with its own sub-filtered trust policy. The api deploy job assumes a role that can touch API infrastructure only. A compromise of one pipeline lane is contained to one blast radius, which is the same principle as OIDC scoping applied one level down.

What to log and alert on

Ephemeral credentials change your detection story from "find the stolen key" to "notice the anomalous exchange." Concretely, on AWS:

  • Alert on AssumeRoleWithWebIdentity calls that fail the trust policy. Denied exchanges mean someone — or some misconfigured workflow — is presenting tokens with unexpected claims. That's either drift or an attack probe; both deserve a look.
  • Alert on your CI roles being assumed outside expected patterns: unusual frequency, unexpected session names (ours embed the run ID, so a session name that doesn't match the format is a red flag), or source IPs outside the runner ranges if you're on self-hosted runners with known egress.
  • Alert on any use of long-lived IAM user keys, period. In a zero-secret setup, the correct number of active access keys for CI is zero, so any iam:CreateAccessKey or authenticated call from an IAM user is a finding by default. This is the payoff: the alert condition becomes simple.
  • On the CI side: new or modified workflow files (especially permissions: escalations and new pull_request_target triggers), changes to environment protection rules, new repository or organization secrets, and unpinned action references landing in PRs. All of these are available via the audit log and are cheap to watch.
  • Keep provenance verification failures loud. An artifact failing gh attestation verify at deploy time should page someone, not silently retry.

Takeaways

  • Long-lived cloud keys in CI secrets are valid everywhere, forever, for everyone who can read the runner's environment — which includes your entire dependency graph. Stop storing them.
  • OIDC federation replaces stored secrets with a signed, claims-rich identity token exchanged per job for credentials that expire in minutes.
  • The sub claim is the control that matters. Filter trust policies to the exact repo and environment (or exact ref). StringEquals, not wildcards. One role per app per environment.
  • Use GitHub environments so production credentials can't be minted without protection rules — required reviewers, branch policies — being satisfied first.
  • Harden the pipeline: pin actions to full commit SHAs, set top-level permissions: contents: read and escalate per job, treat pull_request_target as radioactive, and sign provenance attestations for artifacts.
  • Never let untrusted jobs write caches that trusted jobs restore; key executable caches on exact content hashes and cache download caches, not installed trees.
  • In monorepos, scope both builds (path filters, matrices) and credentials (per-app roles) to the change — blast-radius thinking applies to pipeline lanes, not just infrastructure.
  • Ephemeral credentials simplify detection: failed token exchanges, out-of-pattern role assumptions, and any IAM user key activity become clean, low-noise alert conditions.

None of this is exotic anymore. The providers ship it, the actions support it, and the setup for a typical service is an afternoon. The pipelines that get breached in next year's writeups will be the ones still holding keys — build yours so there's nothing there to take.

Enjoyed the read? We build this stuff for clients too.

Start a project