Secrets in CI/CD Pipelines
⚡ TL;DR: Long-lived CI credentials are a top breach vector, so stop storing secrets at all. Swap stored keys for short-lived, scoped, keyless OIDC auth, keep secrets out of logs and forked PRs, and scan for leaks before
⚡ TL;DR: Long-lived CI credentials are a top breach vector, so stop storing secrets at all. Swap stored keys for short-lived, scoped, keyless OIDC auth, keep secrets out of logs and forked PRs, and scan for leaks before attackers find them.
Contents
- A token in a log gave away production
- The principle: don't store a secret at all
- The picture: OIDC token exchange vs the stored key
- Stored key vs OIDC vs secret manager
- Build it: a GitHub Actions job with OIDC, no stored keys
- Logs and forked PRs: where secrets escape
- Scan for leaks before attackers do
- Common mistakes that cost hours
- Takeaways
- Where to go next
A token in a log gave away production
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.SOMETHINGin 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.
The guiding rule of modern CI/CD auth
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.
| In the real world | In tech |
|---|---|
| A copied house key under the doormat, works forever, for anyone who finds it | A long-lived access key stored as a CI secret |
| A hotel keycard that only opens your room and dies at checkout | A short-lived OIDC credential scoped to one role |
| Showing your ID badge at the door to be let in, then walking through | The pipeline proving its identity via OIDC to mint a token on demand |
| The front desk verifying your badge is real before issuing the card | The 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.
- 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.
- 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.
- 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.
- 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.
💡 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 no AWS_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
name: deploy
on:
push:
branches: [main]
# Least privilege at the workflow level
permissions:
contents: read # checkout
id-token: write # required to request the OIDC token
jobs:
deploy:
runs-on: ubuntu-latest
# Scope to an environment so reviewers/approvals + env secrets apply
environment: 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 job
role-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.
⚠️ Warning: Lock the trust policy to a specific
subclaim (repo + ref + environment). A wildcard likerepo: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.
⚠️ Warning: 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
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_targetto build fork code. Assume a fork run is run by a stranger. -
Echoing or logging a secret,
echo $TOKEN, printing a full command, orset -xover 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
echoa secret; it can leak in forms masking misses. - Never expose secrets to forked-PR builds; avoid
pull_request_targetfor 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.
- Securing the Software Supply Chain, the bigger picture: trust, provenance, and what runs in your build.
- Your First CI Pipeline with GitHub Actions, start here if the workflow syntax above was new.
- 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.
Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.