Secrets Management in GitLab CI: Stop Storing Long-Lived Keys With OIDC
Static cloud keys in CI variables are a breach waiting to happen. Here's how I use GitLab OIDC and short-lived credentials to deploy without storing any long-lived secrets.
- #gitlab
- #cicd
- #secrets
- #oidc
- #security
- #aws
The most dangerous file in many organizations isn’t in the codebase. It’s the list of CI/CD variables — a quiet pile of cloud access keys, database passwords, and API tokens that haven’t been rotated since the person who created them left two years ago.
I’ve cleaned up after credential leaks more times than I’d like. The fix that actually sticks isn’t “rotate more often.” It’s “stop storing long-lived secrets at all.” GitLab’s OIDC support makes that genuinely achievable. Here’s how I do it.
The problem with static CI variables
A static cloud key stored in GitLab has three properties that make it a liability:
- It’s long-lived — valid until someone remembers to revoke it.
- It’s broadly scoped — usually more permissions than the job needs.
- It’s exfiltratable — anyone who can run a job, or read a leaked log, can walk away with it.
You can mask variables and mark them protected, and you should. But masking only hides them in logs; it doesn’t change the fundamental risk. The credential still exists, still works from anywhere, and still lives indefinitely.
OIDC: trade identity for short-lived credentials
OpenID Connect flips the model. Instead of storing a key, GitLab mints a short-lived JWT for each job that proves “this is pipeline X, branch Y, project Z.” Your cloud provider is configured to trust GitLab’s identity provider and hands back temporary credentials scoped to exactly that job.
Nothing long-lived is ever stored. The credential the job receives expires in an hour and only works from a job matching your trust conditions.
Setting it up with AWS
On the AWS side, you create an IAM OIDC provider pointing at your GitLab instance, then a role with a trust policy that pins which pipelines may assume it:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::ACCOUNT:oidc-provider/gitlab.example.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"gitlab.example.com:sub": "project_path:my-group/my-app:ref_type:branch:ref:main"
}
}
}
That sub condition is the whole security boundary. It says only the main branch of my-group/my-app can assume this role. A feature branch, a fork, or another project gets nothing.
In the pipeline, you request the token and exchange it:
deploy:
id_tokens:
AWS_TOKEN:
aud: https://gitlab.example.com
script:
- >
export $(aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_ARN"
--role-session-name "ci-$CI_PIPELINE_ID"
--web-identity-token "$AWS_TOKEN"
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text | awk '{print "AWS_ACCESS_KEY_ID="$1"\nAWS_SECRET_ACCESS_KEY="$2"\nAWS_SESSION_TOKEN="$3}')
- aws s3 sync ./dist s3://my-bucket/
No static key anywhere. The same pattern works for GCP workload identity federation, Azure federated credentials, and HashiCorp Vault’s JWT auth.
Scope your trust conditions tightly
The most common OIDC mistake is a loose sub claim. If your condition is just project_path:my-group/my-app:*, then any branch — including an attacker’s feature branch in a compromised account — can assume a production role. Pin the branch or the environment. Treat the sub like a firewall rule.
GitLab exposes rich claims you can match on: ref, ref_type, environment, namespace_path, and more. Use them.
For secrets that can’t be federated
Some secrets are genuinely static — a third-party API key, a signing certificate. For those, don’t dump them in CI variables. Use a real secrets manager and pull at runtime:
deploy:
secrets:
DB_PASSWORD:
vault: production/db/password@ops
GitLab’s secrets keyword integrates with Vault using the same OIDC token, so even your “static” secrets are fetched just-in-time and never stored in the project. The secret lives in one auditable place with rotation and access logs.
Hygiene that still matters
Even with OIDC, keep the basics:
- Mask and protect any remaining variables so they don’t leak into logs and only run on protected branches.
- Never echo secrets, and disable debug tracing on jobs that touch them.
- Audit variable lists quarterly. Every static credential you find is a question to answer: why does this still exist?
Where AI helps
OIDC trust policies are easy to misconfigure in dangerous ways. I paste the IAM trust policy and the GitLab id_tokens block into a model and ask: “Which branches or projects can assume this role, and is the sub condition too permissive?” It’s caught wildcard conditions I’d have shipped. I keep GitLab CI prompts for these audits, and run security-sensitive pipeline changes through our Code Review tool before merge.
The payoff
Move to OIDC and the scariest file in your org — that pile of immortal cloud keys — simply stops existing. Credentials become short-lived, tightly scoped, and impossible to exfiltrate in a useful way. A leaked CI log becomes a non-event instead of a breach.
You can’t lose a secret you never stored. That’s the whole idea.
AI suggestions for secrets and trust-policy configuration are assistive, not authoritative. Always review OIDC trust conditions with your security team before granting production access.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.