CI/CD secrets management covers the credentials your pipelines use: cloud keys, database passwords, API tokens, deploy keys and SSH keys. Most DevOps secrets management advice is about storage, such as which vault holds the password and how often it rotates. That still hands a usable secret to the CI runner on every job.
This guide asks a different question: why does the runner need the secret at all? With OIDC, short-lived credentials and injection at a proxy, most pipeline secrets never have to reach the runner. Two recent compromises of popular GitHub Actions, tj-actions in 2025 and Trivy in 2026, show why that matters: both read secrets straight off runners.
What is CI/CD secrets management?
CI/CD secrets management is the practice of storing, delivering, scoping, rotating and auditing the credentials that build and deploy pipelines use to reach other systems. A pipeline needs them to push an image to a registry, apply Terraform to a cloud account, run a database migration or call a third-party API. It’s one part of CI/CD security, alongside build integrity and dependency control.
Every CI platform ships a basic version: GitHub Actions secrets, GitLab CI/CD variables, the Jenkins credential store. External vaults add central storage, access policies and automated rotation. Both models end the same way. At job time, the secret’s value is handed to the runner, usually as an environment variable or a file, and the job uses it directly.
The newer model replaces stored secrets with identity. The CI platform signs a token for each job that says which repository, branch and workflow it belongs to, and the target system exchanges it for a credential that expires with the job. OWASP’s Top 10 CI/CD security risks include insufficient credential hygiene (CICD-SEC-6) and inadequate identity and access management (CICD-SEC-2); identity-based access addresses both.
Why are pipeline secrets on CI runners a risk?
A secret delivered to a runner is available to everything that runs in that job: your scripts, every third-party action, every package install script, and anyone who can change the workflow. Log masking hides known values in the output, but the process still holds the value in its environment and memory. The common ways pipeline secrets leak:
- Secrets in logs. A debug step, a verbose tool or an encoded copy of the value prints a secret that masking doesn’t recognize.
- Third-party actions and dependencies. An action runs with the job’s environment and file system. If its tag is moved to malicious code, it reads whatever the job holds.
- Poisoned pipeline execution. Someone who can change the workflow or a script it calls, through a pull request for example, makes the job send its secrets elsewhere (OWASP CICD-SEC-4).
- Copied long-lived credentials. The same deploy key or cloud key is pasted into many repositories and never expires, so one leak exposes all of them.
- Persistent self-hosted runners. A runner that isn’t rebuilt between jobs can keep files, caches and processes from earlier jobs.
Over time this produces secret sprawl: copies of one credential in repository secrets, organization secrets, vault paths, .env files on developer laptops and forgotten pipelines, with nobody sure which copies are still live. Hardcoded secrets in workflow files and scripts are the worst case, because they also land in version control and in every clone of it.
What did the tj-actions and Trivy compromises take?
In March 2025, the tags of tj-actions/changed-files, an action that lists the files changed in a pull request, were moved to a malicious commit (CVE-2025-30066). CISA’s alert says it exposed secrets including access keys, GitHub personal access tokens, npm tokens and private RSA keys, printed in workflow logs as double-encoded base64. The fix was v46.0.1, plus rotating every exposed secret.
In March 2026, an attacker with stolen credentials force-pushed 76 of the 77 version tags of aquasecurity/trivy-action to credential-stealing code (GitHub advisory GHSA-69fq-xp46-6x23). Before the real scan ran, it dumped the memory of the runner’s worker process to extract secrets, then swept the file system for SSH keys, cloud credentials, Kubernetes tokens and .env files.
The lesson from both: any secret the job can read, a compromised step can read too. Masking couldn’t help against the Trivy payload, because it took secrets from process memory, not from the logs. The only secret it couldn’t steal was one the runner never had.
Why isn’t secret rotation enough?
Rotation limits how long a stolen secret stays useful, but it doesn’t prevent the theft, and it has its own exposure window. The Trivy advisory says the March 19 attack continued an incident disclosed on March 1, 2026. Credentials were rotated after that disclosure, but not all at once, and during the few days the rotation took, the attacker could have used a still-valid token to steal the newly rotated secrets.
Removing the secret beats rotating it. A credential issued for one job, which expires with that job, never needs rotating, and stealing it gives an attacker a short window inside one run instead of months of reuse.
How do you get secrets off CI runners?
Getting secrets off CI runners takes three steps, in this order:
- Give each job an identity instead of a secret. The CI platform signs an OIDC token (a JWT) for each job, with claims such as the repository, branch, environment and workflow. Nothing is stored: the token is issued when the job asks for it.
- Exchange that identity for short-lived credentials. The target system, or a broker in front of it, verifies the token’s issuer, audience and claims, then returns a credential that lasts minutes to an hour and is scoped to what the job needs. It’s just-in-time access applied to a machine.
- Inject static credentials at a proxy. Some systems only accept a password, a key or an API token. A proxy in front of them holds that credential and adds it to the connection after checking the job’s identity, so the runner never receives it.
Steps 1 and 2 cover most cloud APIs today. Step 3 covers what’s left, which is usually where the most sensitive secrets are: production databases, SSH hosts and third-party APIs. Together they give you secretless CI, where no runner holds a reusable credential.
How does GitHub Actions OIDC replace stored secrets?
GitHub Actions OIDC lets a job request a signed token from GitHub’s identity provider, https://token.actions.githubusercontent.com, instead of reading a long-lived key from GitHub Actions secrets. The job needs the id-token: write permission. The token’s claims name the repository, the branch or tag (ref), the environment and the workflow, and the sub claim combines them, for example repo:octo-org/octo-repo:environment:prod.
The cloud provider trusts GitHub’s issuer and checks those claims before it issues anything. GitHub’s OIDC documentation describes the result: cloud credentials no longer duplicated as GitHub secrets, and a short-lived access token that’s valid for a single job and then expires. AWS, Azure and Google Cloud support the flow natively.
Here’s the catch: the trust condition carries most of the security. A cloud role that accepts any token from GitHub’s issuer, or only checks the organization, accepts tokens from workflows you never meant to trust. Tie it to your repository and, for production, to a protected branch or environment.
GitLab OIDC, CircleCI and other CI systems
With GitLab OIDC, GitLab CI/CD issues ID tokens to jobs that request them with the id_tokens keyword, with claims for the project, the ref and the pipeline. CircleCI and Buildkite also issue OIDC tokens to jobs. The pattern is the same everywhere: the job gets a signed token, and the target verifies its issuer, audience and claims before handing out a short-lived credential.
What about databases, SSH hosts and API keys?
OIDC takes cloud keys off CI runners, but most databases, SSH servers and third-party APIs don’t accept an OIDC token. A Postgres database usually wants a password, an SSH host wants a key, and a payments API wants its API key. Without another layer, these static credentials are the secrets left on the runner, and they’re often the most sensitive ones in the pipeline.
Credential injection closes that gap. A proxy sits between the job and the system. The job authenticates to the proxy with its short-lived identity, and the proxy checks that identity against a policy, then opens the upstream connection with the real credential, which it holds or fetches from a secrets manager at connection time. The runner only ever sees its own token.
The proxy also becomes the place to decide and record what each job does: which repository and branch may connect, to which database, and which queries or commands ran in each pipeline run. That record is per run, not per shared service account.
How Formal keeps credentials off CI runners
Formal implements all three steps. A CI job authenticates to Formal with its OIDC token through an OIDC integration. Formal verifies the signature, issuer, audience and a CEL claim condition, then maps the job to a machine user. This condition, from the Formal docs, accepts only jobs on the main branch of one repository:
claims.sub == 'repo:formalco/monorepo:ref:refs/heads/main'
The job then calls CreateFederationToken to mint a short-lived Connector credential. Its lifetime is capped at one hour and never runs past the GitHub token it came from, so the credential dies with the job’s identity. Minting is reserved for machine users that signed in with OIDC: neither a Formal API key nor a person’s session can call it. Connector policies (Connector 2.16.1 or later) read the source claims, such as input.user.oidc.claims.repository and input.user.oidc.claims.ref, to allow one repository and branch and block the rest.
Upstream, the Formal Connector signs in as a Native User: a password, an SSH key, a Snowflake key, AWS, GCP or Azure IAM, a kubeconfig, or an HTTP Bearer token or API key. Static values can live in Formal or in the Connector’s own environment; a hook can instead pull a fresh credential, from Vault for example, each time a connection opens. Either way, the job only presents its federation token and never handles the upstream credential.
Control-plane audit events record the integration and a bounded copy of the claims, never the raw JWT. When a workload’s token carries a claim that identifies a person, an End-User Email Expression attributes the session to that person, and minting fails closed if no matching user exists. Policies and audit for these machine users live in the Formal platform, and credential injection is part of Formal PAM.
We use the same OIDC integration ourselves: Terraform Cloud authenticates to our control plane with OIDC instead of a long-lived API key, and our cloud agents get scoped, short-lived production access. Giving Cloud Agents Production Access with OIDC explains how.
Before and after: a database migration job
Before: a GitHub Actions workflow runs migrations on every merge to main. The production DATABASE_PASSWORD lives in repository secrets and arrives in the job’s environment. Every step and action in the job can read it, it never expires, and the database’s logs show only a shared migration user, not which workflow run made each change.
After: the job has permissions: id-token: write and no database secret. It requests a GitHub OIDC token with the Formal audience (oidc.formal.ai/<integration_id>), mints a federation token and runs the migration through the Connector, with the returned username and token as the database login. The real password exists only on the Connector, and a policy allows only main of that repository.
The OIDC integration guide covers the setup, including the formal_integration_oidc Terraform resource with the issuer https://token.actions.githubusercontent.com.
How do you secure self-hosted runners?
Self-hosted runner security matters more because those runners sit on your network and reach internal systems. A runner that isn’t rebuilt between jobs can keep files, caches and processes from earlier jobs, so a secret one pipeline writes to disk can outlast it. Ephemeral runners, recreated for each job, reduce that risk; storing nothing on them removes most of it.
GitHub-hosted and self-hosted runners receive the same OIDC tokens, so the three steps apply unchanged. With Formal, the job’s own token is also how the Endpoint authenticates in CI, so no Formal key is stored on the runner: grant the job id-token: write and start the Endpoint in the background from a run step, a composite action or the runner’s job-started hook. On GitHub Actions, it can renew its token while the job runs.
Other CI systems, such as GitLab, can pass a JWT through an environment variable. The Endpoint can’t renew a token supplied that way, so restart it with a new token before the current one expires. From the job, the Endpoint forwards selected requests to a Connector that adds the credential, the same credential injection Formal Endpoint applies to AI agents.
DevOps secrets management best practices for the secrets you still store
Some secrets will stay in CI for a while: a signing key, a token for a service without OIDC support, a vendor API key. For those, these security best practices still apply:
- One secret per pipeline and purpose. Don’t share a deploy key across repositories; a leak should expose one pipeline, not twenty.
- Least privilege. Give each credential the narrowest role that works, such as push access to one registry instead of an admin key.
- Never print secrets. Masking catches only exact values it knows; encoded or transformed copies pass through.
- Rotate on a schedule and after any exposure. After an incident, rotate every affected secret at the same time and revoke the old values immediately.
- Audit use. Know which pipelines read each secret and when, and delete secrets that nothing reads.
GitHub Actions secrets best practices
- Scope secrets to environments. Put production secrets in an environment with protection rules, such as required reviewers and allowed deployment branches, so only approved jobs from protected branches receive them.
- Minimize
GITHUB_TOKENpermissions. Setpermissionsat the workflow or job level and grant each job only what it needs. - Pin third-party actions to a full commit SHA, not a tag. Both incidents above moved tags. Then check whether pinned actions fetch other actions or tools by tag.
- Prefer OIDC to stored cloud keys wherever the target supports it, and keep repository and organization secrets for what’s left.
Each of these makes a stored secret harder to steal and less useful when stolen. None of them makes it absent, which is why the order matters: remove what you can with OIDC and injection first, then apply these controls to what remains.