Why pipelines are a favourite target
A CI system holds some of the most powerful credentials in your company. It can read your source, push to your registry and deploy to production. If an attacker, a compromised dependency or a careless script gets hold of those credentials, the damage is large and quiet. Yet in many startups the pipeline’s secrets are a handful of long-lived cloud access keys pasted into settings pages two years ago, shared across projects and known to a former contractor.
Good secrets management makes the pipeline less valuable to attack and a leak less costly. It is a core part of our CI/CD pipeline service, and this page describes the approach.
What good looks like
- No long-lived cloud credentials in CI. Jobs authenticate through federated identity and receive short-lived tokens.
- Least privilege per job. A test job cannot deploy. A staging job cannot touch production.
- A single source of truth for application secrets with audit logs, versioning and rotation.
- Secrets stay out of images, repositories and logs.
- Fast revocation. You can find where a secret is used and replace it quickly.
Step one: remove cloud keys with OIDC
The largest single improvement is eliminating stored access keys. GitHub Actions, GitLab CI and Azure DevOps can each issue a signed OpenID Connect token that identifies the repository, branch and environment of a running job. AWS, Azure and Google Cloud can trust those tokens and issue temporary credentials in exchange.
In GitHub Actions with AWS, a job looks like this:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy-production
aws-region: eu-west-1
- run: aws sts get-caller-identity
The account ID above is a documentation-style example. The important part is the role’s trust policy, which restricts who may assume it, for example only the main branch or the production environment of one repository:
{
"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:your-org/your-repo:environment:production"
}
}
}
A missing or loose sub condition is the most common mistake we see: it lets any repository in the organisation, or even any GitHub repository, assume the role. We review these policies carefully. Azure uses federated credentials on an app registration, and the principle is identical.
Step two: pick a store for everything else
OIDC handles cloud access. Applications still need database passwords, API keys and signing certificates. These belong in a dedicated store.
| Option | Strengths | Costs and limits |
|---|---|---|
| AWS Secrets Manager | Native IAM, rotation for supported databases, little to operate | Per-secret pricing; AWS only |
| Azure Key Vault | Native Entra ID access control, certificates and keys | Azure only |
| HashiCorp Vault | Multi-cloud, dynamic short-lived database credentials, rich policies | You run, upgrade and secure it, or pay for a managed edition |
Our default advice: if you live mostly in one cloud, use that cloud’s service and avoid operating a secrets platform. Choose Vault when you need dynamic credentials generated per job, a consistent model across clouds or on-premises, or finer policy control. It is powerful, but a mis-run Vault becomes a single point of failure.
Getting secrets to workloads
For Kubernetes, avoid putting plaintext in manifests. Options include the External Secrets Operator, which syncs from a cloud store into native Kubernetes secrets, the Secrets Store CSI driver, which mounts secrets as files, or Sealed Secrets for encrypting values committed to Git. Each has trade-offs: External Secrets keeps one source of truth, whereas Sealed Secrets suits small teams wanting everything in Git. Understanding how ConfigMaps and Secrets behave matters too, because Kubernetes Secrets are base64-encoded, not encrypted, unless you enable encryption at rest.
Step three: stop leaks
Prevention is cheaper than rotation.
- Push protection and scanning. Block commits containing known credential patterns, and scan history with a tool such as Gitleaks.
- Scoped, environment-level secrets. Variables for production are only available to jobs on a protected branch targeting the production environment, as part of promotion gates.
- Safe handling in scripts. Avoid
set -xaround sensitive commands, do not pass secrets as command-line arguments (they show in process lists), and prefer files or environment variables. - Fork protections. Pull requests from forks should not receive secrets. Be careful with
pull_request_targetstyle triggers, which can expose secrets to untrusted code. - Pin third-party actions. A compromised action runs with your job’s permissions. Pinning to a commit SHA and reviewing updates reduces that risk.
Masking in CI logs is a convenience, not a security control. It only recognises exact known values, so a base64-encoded or split secret slips through.
Decisions and trade-offs
Dynamic versus static secrets. Vault can create a database user per job that expires in minutes. This greatly limits blast radius but needs the database integration to be reliable. Static secrets with rotation are simpler and good enough for many teams.
Central store versus CI variables. A central store adds a dependency: if it is down, deploys may stall. We design fallbacks and caching sensibly, and keep the secret store highly available proportional to its importance.
Self-hosted runners. They can use instance roles, which avoids secrets entirely, but shared runners that serve many projects risk cross-job leakage. We prefer ephemeral runners.
When not to over-engineer. A small team with two services and one cloud does not need Vault. OIDC plus the cloud secret manager covers most of the value.
Common failure modes
- Overly broad trust policies on OIDC roles.
- One shared “ci-user” with administrator rights.
- Secrets copied into several projects, then only rotated in some.
- Secrets baked into Docker images, visible to anyone who can pull them. Our image build and scan stage checks for this.
- No rotation because nobody knows where a secret is used.
- Credentials in Terraform state or logs. State needs encryption and tight access.
Delivery and handover
We start with an inventory and a risk-ranked plan, then replace cloud keys first because that gives the biggest reduction in risk. Secrets are migrated to the chosen store service by service, with the old values revoked once confirmed unused. You receive a register of secrets with owners, rotation schedules and a revocation runbook that we test with your team.
How to tell it is working
Count long-lived credentials in CI (the target is zero for cloud access), measure the age of your oldest secret, check that secret scanning findings trend down, and test that a revoked credential genuinely stops working. Also confirm that a job on a feature branch cannot assume your production role. If you want a review of your current setup, talk to us.