A CI variable marked “masked” or “protected” looks handled. It is stored encrypted, hidden from the job log, restricted to certain branches. Teams stop worrying about it at that point, which is exactly the wrong moment to stop: masking only controls whether the value is displayed. It says nothing about how long that value stays valid, what happens the day it leaks anyway, or what a compromised job can do with it in the meantime. That gap between “hidden” and “safe” is where most CI secret incidents actually happen, and it is also what a newer pattern, federated identity, is built to close.
How a static credential actually leaks
Three ordinary paths account for most leaks, and none of them require a sophisticated attacker.
Debug output. A job that runs env for troubleshooting, or a script that echoes a variable while debugging a failing step, prints the secret straight into the log. Most CI platforms scan known secret values and redact them in the raw log view, but that scanning only catches the exact string it was told to mask; a base64-encoded copy, a substring, or a value assembled at runtime sails through unredacted.
Forked pull requests with elevated triggers. GitHub Actions’ pull_request_target runs with the base repository’s permissions and secrets, but checks out and can execute code from the fork. A workflow that blindly checks out github.event.pull_request.head.sha under that trigger hands an external contributor’s code a path to your production secrets. This is a well-documented trap, not an edge case, precisely because the workflow that has the secrets is not the same workflow reviewing the code that runs.
Long-lived keys with no rotation pressure. A static access key stored as a CI variable three years ago, still valid, still scoped broadly because narrowing it “later” never happened, is a standing liability independent of any single leak: anyone who ever gained read access to that variable, at any point in its history, effectively still has it until someone manually rotates it.
What OIDC federation changes
The structural fix is to stop storing a credential at all. With OpenID Connect federation, the CI platform itself issues a short-lived, signed JSON Web Token identifying the specific run: which repository, which branch, which workflow. The cloud provider is configured to trust that issuer and, on request, exchanges the token for temporary cloud credentials, valid for the duration of the job and scoped to a role you defined in advance.
# .github/workflows/deploy.yml
permissions:
id-token: write # lets this job request an OIDC token from GitHub
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy
aws-region: eu-west-1
# no AWS_ACCESS_KEY_ID, no AWS_SECRET_ACCESS_KEY anywhere in this repo
On the AWS side, the role’s trust policy names the exact GitHub repository and branch allowed to assume it, using claims embedded in the token:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:acme/app:ref:refs/heads/main"
}
}
}
The credential handed back is scoped, time-boxed (typically under an hour), and never stored anywhere; a job that finishes simply loses access. There is nothing left to leak in a log, because there is no long-lived secret sitting in a CI variable to begin with. GitLab CI ships the equivalent as id_tokens, Azure and GCP have their own workload identity federation setups, and the shape is the same across all three: the CI run authenticates by proving who it is, not by presenting something it was handed.
Where this fits, and where it does not
This is not a replacement for Vault or Sealed Secrets: those solve storing and rotating credentials an application reads once it is running inside the cluster. OIDC federation solves a narrower, earlier problem: how the pipeline itself gets cloud access to build and deploy, before any application secret enters the picture. A mature setup typically uses both: OIDC for the CI-to-cloud handshake, and a proper secrets manager for what the running application needs afterward.
The remaining case for a static credential is a provider or a self-hosted target with no federation support. There, the right mitigation is narrow IAM scope, forced rotation on a schedule, and the same treatment for pull_request_target regardless: never let a workflow that holds secrets execute untrusted fork code from the same trigger.
What to check first
Grep your CI configuration for AWS_SECRET_ACCESS_KEY, AZURE_CLIENT_SECRET, or equivalent variables sitting in project settings. Every one you find is a standing liability with an expiration date nobody set. Migrating the highest-privilege pipeline first (typically the one that deploys to production) to OIDC removes the single credential whose leak would hurt the most, and it is one of the concrete steps inside industrializing a CI/CD chain: the goal is not more automation, it is a pipeline that has fewer permanent secrets to lose in the first place.