Long-lived credentials in CI are one of the top breach vectors. Learn to swap stored keys for short-lived, scoped, keyless OIDC auth, and keep secrets out of logs and forked PRs.
A team stored a long-lived cloud access key as a CI secret so their pipeline could deploy. It worked for a year. Then someone added a debugging line, echo "deploying with $AWS_ACCESS_KEY_ID", to a pull request. The build ran, the value landed in the public build log, and a bot scraping that CI provider had it within minutes. The key had broad permissions and never expired, so the attacker did not need to do anything clever: they just used it. They read every S3 bucket, spun up compute for crypto mining, and were inside production before the on-call engineer finished their coffee.
Nothing here was exotic. A real credential, stored where automation could reach it, leaked through an ordinary mistake, and because it was long-lived and broadly scoped, the blast radius was the whole account. This is one of the most common ways companies get breached, and almost all of it is preventable.
Who this is for
You write or maintain CI/CD pipelines (GitHub Actions, GitLab CI, Jenkins) that need to talk to a cloud or another system. You have seen `secrets.SOMETHING` in a workflow and want to understand how to do it **safely**, ideally without storing a permanent key at all. No security background assumed.
The principle: don't store a secret at all
The safest secret is the one that does not exist. Prefer no static secret at all, issue short-lived, scoped, keyless credentials at the moment of use.
Every credential you store is a liability that sits there 24/7 waiting to leak. The modern answer is OIDC (OpenID Connect): instead of stashing a permanent key, your pipeline proves *who it is* to the cloud at runtime, and the cloud hands back a credential that is short-lived (minutes), scoped (one role, one job), and keyless (nothing is stored anywhere). If it leaks, it has already expired by the time anyone finds it.
A copied house key under the doormat, works forever, for anyone who finds itA long-lived access key stored as a CI secret
A hotel keycard that only opens your room and dies at checkoutA short-lived OIDC credential scoped to one role
Showing your ID badge at the door to be let in, then walking throughThe pipeline proving its identity via OIDC to mint a token on demand
The front desk verifying your badge is real before issuing the cardThe cloud verifying the OIDC token's signature and claims before granting a role
A stored key versus a keyless, short-lived pass.
The picture: OIDC token exchange vs the stored key
Top row: the keyless OIDC path, the pipeline trades an identity token for short-lived cloud creds. Bottom: the bad path, a stored long-lived key with nothing standing between a leak and production.
1
The job starts and requests an identity token
The CI platform mints a signed OIDC token describing the job: which repo, which branch, which workflow, which environment. The job never sees a stored key.
2
The cloud verifies the token
Cloud STS checks the token's signature against the provider's public keys and matches its claims (repo, branch, environment) against a trust policy you configured.
3
The cloud hands back short-lived credentials
If the claims match, STS returns temporary credentials scoped to exactly one role, valid for minutes, not forever.
4
The job uses them and they expire
Deploy, run, finish. The credentials vanish on their own. Nothing was stored, so there is nothing to rotate, leak, or steal long-term.
Stored key vs OIDC vs secret manager
Not every system supports OIDC yet, so you will still meet stored secrets and dedicated secret managers. Here is how the three approaches compare on the things that actually cause incidents, leak risk and rotation burden.
Approach
Leak risk
Lifetime
Rotation
Stored long-lived secret
High, one log line or leaked dump = permanent access
Forever, until manually revoked
Manual, easy to forget; nobody knows where it has been copied
Secret manager (e.g. Vault, cloud secret store)
Medium, central, audited, access-controlled, but still a real value in transit
Configurable; often rotated automatically
Automated, central; far better than scattered CI secrets
OIDC keyless
Low, no stored credential; tokens expire in minutes and are scoped to one job
Minutes
None needed, there is nothing static to rotate
The further down this table you go, the less there is to leak.
Pro tip
Reach for OIDC first. When a target truly cannot do OIDC, store the secret in a **secret manager** and pull it at runtime, never paste a raw long-lived key into your CI provider's secret store as the default.
Build it: a GitHub Actions job with OIDC, no stored keys
This job assumes an AWS role using OIDC. There is noAWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY anywhere, the id-token: write permission lets the runner request an identity token, and the configure-credentials action trades it for short-lived creds. The only thing stored is a role ARN, which is not a secret.
.github/workflows/deploy.yml
yaml
name: deploy
on:
push:
branches: [main]
# Least privilege at the workflow levelpermissions:
contents: read # checkoutid-token: write # required to request the OIDC tokenjobs:
deploy:
runs-on: ubuntu-latest
# Scope to an environment so reviewers/approvals + env secrets applyenvironment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (keyless)
uses: aws-actions/configure-aws-credentials@v4
with:
# Just an ARN, not a secret. The trust policy on this role# restricts which repo/branch/environment may assume it.role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: eu-west-1# Short session: credentials expire ~15 min after the jobrole-duration-seconds: 900
- name: Deploy
run: |
aws sts get-caller-identity # who am I? (short-lived role)
aws s3 sync ./dist s3://my-app-prod --delete
On the cloud side you create a role whose trust policy says "only a token from this OIDC provider, for *this* repo and *this* branch, may assume me." That binding is what makes the credential keyless and scoped, a fork or a different repo cannot satisfy the trust policy, so it gets nothing.
Watch out
Lock the trust policy to a specific `sub` claim (repo + ref + environment). A wildcard like `repo:my-org/*` lets *any* repo in your org assume the role, that is a quiet way to hand prod to a low-trust project.
Logs and forked PRs: where secrets escape
Even with great auth, secrets leak through two everyday channels: build logs and pull requests from forks.
Log redaction (and why it is not enough)
CI platforms mask registered secrets in logs, if a value matches a known secret, it is replaced with ***. This is a useful safety net, but it is the *last* line of defense, not a strategy. Masking only catches the exact stored string; it misses a secret you derived, base64-encoded, split across lines, or printed by a tool you called. Never rely on masking to save you from echo-ing a credential. The real fix is upstream: do not put long-lived values in the environment in the first place, and never deliberately print them.
The forked-PR trap
When an outside contributor opens a pull request from their fork, their code runs in your pipeline. If that pipeline exposes your secrets to the PR build, the attacker simply adds a step that exfiltrates them, curls the environment to a server they control. The defense: on pull_request from forks, CI platforms withhold secrets and use read-only tokens by default. Do not defeat that protection. The dangerous move is pull_request_target, which runs with full repo permissions *and the base repo's secrets* against untrusted PR code, only ever use it for the narrow, code-free tasks it was designed for (like labeling), never to build or test fork code.
Watch out
Treat any workflow that grants secrets to fork-submitted code as a vulnerability. If a fork's CI run can read a credential, assume a stranger can too.
Scan for leaks before attackers do
Secrets end up committed to git constantly, a .env file, a key pasted into a test, a config dump. Run a secret scanner in CI so a leaked credential fails the build instead of shipping. These tools grep history and diffs for high-entropy strings and known credential patterns.
.github/workflows/scan.yml
yaml
name: secret-scan
on: [push, pull_request]
permissions:
contents: read
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0# scan full history, not just the tip
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
Also turn on your platform's push protection (e.g. GitHub secret scanning) so a recognized credential is blocked at git push, before it ever reaches the server. And remember: once a secret hits a remote, treat it as compromised. Scanning tells you to rotate it, not just delete the commit.
Common mistakes that cost hours
Exposing secrets to fork PRs, letting untrusted code from a fork read your credentials, or misusing pull_request_target to build fork code. Assume a fork run is run by a stranger.
Echoing or logging a secret, echo $TOKEN, printing a full command, or set -x over a line that contains one. Masking may not catch derived or re-encoded values.
No rotation, no expiry, a long-lived key that has worked "for years" is a credential that has been copied to laptops and screenshots you cannot account for. Prefer short-lived; if you must store, rotate on a schedule.
Over-broad scope, one admin key for the whole pipeline. Scope per environment and per job; a deploy job does not need read access to every bucket.
Pasting raw keys into CI secret stores by default, convenient, but it is still a stored long-lived credential. Use OIDC, or pull from a secret manager at runtime.
Takeaways
The whole article in seven lines
Long-lived secrets stored in CI are a top breach vector, they leak through ordinary mistakes and never expire.
Prefer **no static secret at all**: use OIDC for short-lived, scoped, keyless credentials minted at runtime.
When OIDC is not possible, pull from a **secret manager**, never paste raw long-lived keys as the default.
Scope credentials per **environment** and **job**; lock OIDC trust policies to a specific repo/branch/environment.
Log masking is a safety net, not a strategy, never `echo` a secret; it can leak in forms masking misses.
Never expose secrets to **forked-PR** builds; avoid `pull_request_target` for untrusted code.
**Scan** for leaked secrets in CI and enable push protection, and rotate anything that leaks, do not just delete the commit.
Where to go next
Secrets in pipelines sit at the intersection of CI/CD and security. Go deeper on the storage side, then widen out to the whole delivery chain.
Secrets Management, how to store, scope, and rotate the secrets you genuinely must keep.
Practice in the CI/CD lab, build and break a pipeline hands-on.
Follow the full DevOps Engineer path to see where this fits in the bigger journey.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.