Giving CI only the secrets it needs
Long-lived CI tokens work from anywhere until revoked. How to scope pipeline secrets, keep production out of CI, and bind tokens to a repo with OIDC.
Crunu teamPublished 7 min read
On this page
A CI system is a machine that runs code from many people, on many branches, often on every pull request. It is also, in many teams, the place where production credentials are easiest to reach. A single repository secret holding a production database URL is available to every job that can read it, and a token stored in CI settings keeps working from anywhere until someone remembers to revoke it.
This post covers three habits that shrink that exposure: give each pipeline its own narrow credential, keep production secrets out of CI where you can, and when CI does need production, bind the credential to the exact job with OIDC.
What can go wrong with CI secrets
It helps to name the failure modes before choosing defenses.
- Too broad. One token reads every environment, so a test job holds production keys it never uses.
- Too long-lived. A token created two years ago, copied between repositories, works today from a laptop just as well as from the pipeline.
- Too reachable. Any workflow on any branch can read repository secrets, including a branch where someone added a step that prints them.
- Too quiet. Nobody can say which jobs fetched which values, or from where.
The OWASP Secrets Management Cheat Sheet recommends dedicated, least-privilege identities for pipelines and keeping high-value, long-lived secrets out of CI tooling. The rest of this post turns that into concrete setup.
Habit 1: one narrow token per pipeline
Start by splitting. Each pipeline gets its own credential, and that credential reads only the environment the pipeline needs. Tests read development or staging. A preview deployment reads its preview environment. Nothing that runs on pull requests reads production.
In Crunu, a pipeline uses a service token. It is created in the admin's browser under Machines and tokens, shown once and stored in the CI's protected variables as CRUNU_TOKEN. The server knows only its public keys. When creating it you choose:
- which environments it receives, for example only
web-app / staging; - a lease: an inactivity window from 5 minutes to 30 days, so a token that sits unused for longer stops signing in until an admin resumes it;
- a usage limit, from 1 to 1,000,000 sign-ins;
- the networks it may connect from;
- optionally, a CI binding (OIDC), covered below.
A token that receives only staging never gets the production environment key, so even if it leaks, it cannot decrypt production. That is a property of the keys, not a server-side setting.
In the pipeline, crunu run signs in with the token, checks signatures, decrypts in memory and starts your command with the variables:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install crunu
run: |
curl -sSf "${{ vars.CRUNU_INSTALL_URL }}" -o /tmp/crunu-install.sh
echo "${{ vars.CRUNU_INSTALL_SHA256 }} /tmp/crunu-install.sh" | sha256sum -c -
sh /tmp/crunu-install.sh --version "${{ vars.CRUNU_VERSION }}" --sha256 "${{ vars.CRUNU_BINARY_SHA256 }}" --user-install
- name: Test
env:
CRUNU_TOKEN: ${{ secrets.CRUNU_TOKEN }}
CRUNU_SERVER: ${{ vars.CRUNU_SERVER }}
run: exec crunu run --env web-app/staging -- npm testThe install values come from the CI setup block on the token's page, which shows the command with exact checksums from the signed release manifest. Two details are deliberate. crunu run never passes its own credential variables, such as CRUNU_TOKEN, to the command it starts. And exec replaces the step's shell with crunu, so the token does not linger in a parent process the command could inspect. Nothing is written to disk. The crunu run reference lists every option.
Ready-made wrappers exist for GitHub Actions (a setup-crunu action you copy into your repository), GitLab CI (a .crunu template) and Bitbucket Pipelines (a pipe). They are not published in marketplaces; see GitHub Actions, GitLab CI and Bitbucket Pipelines.
Habit 2: keep production out of CI
The strongest way to protect production secrets in CI is not to put them there. Many deployments do not need CI to know a single production value. The pipeline builds and ships code; the production machine fetches its own secrets.
With Crunu, the production machine runs the agent as a service. The application starts through crunu run, which takes variables from the agent's memory, and the pipeline only restarts the service:
# /etc/systemd/system/payments-api.service
[Service]
User=payments
ExecStart=/usr/local/bin/crunu run --env payments-api/production --restart on-change -- /opt/payments/bin/serverThe payments user must belong to the crunu group to talk to the agent. The deploy step is something like ssh deploy@api-prod-1 sudo systemctl restart payments-api. The pipeline never sees a production value, and a leaked CI token gives an attacker nothing to decrypt. The full setup is in secrets on servers without files on disk.
Habit 3: when CI needs production, bind it with OIDC
Sometimes CI genuinely needs a production value, for example to push variables into a platform that stores them itself. For those cases, a stored token alone is not enough. You want the credential to work only inside the right job.
That is what OpenID Connect gives you. CI providers can issue each job a short-lived identity token: a JWT signed by the provider, naming who it is for and describing the job. As in OpenID Connect Core, it carries an issuer, a subject, an audience and an expiry. The receiving service verifies the signature and checks the claims against its rules.
Each provider exposes it a little differently:
- GitHub Actions issues the token only to jobs granted the
id-token: writepermission. Its claims includerepository,refand, when the job uses one,environment(GitHub Docs). - GitLab CI issues tokens you declare with the
id_tokenskeyword, each with its ownaud. Claims includeproject_path,ref,ref_typeandenvironment. A token expires at the job's timeout, or after five minutes if no timeout is set (GitLab Docs). - Bitbucket Pipelines acts as an OIDC identity provider for pipeline steps, and you can set custom audiences under
oidcin the step (Atlassian Support).
In Crunu, OIDC is a binding on a service token rather than a replacement for it. You add a CI binding (OIDC) on the token's page and write rules: the provider, the repository, the branch, and for production, the deployment environment. With a binding in place, the token signs in only together with a matching JWT from the CI provider, and a replayed JWT is rejected. The agent picks up the job's token by itself on GitHub Actions, GitLab CI and Bitbucket Pipelines. Any other OIDC issuer can be configured as a generic provider.
On GitHub Actions, the only change to the job is the permission:
permissions:
id-token: write
contents: readOn GitLab CI, you declare the token with your Crunu server address as the audience:
deploy:
id_tokens:
CRUNU_ID_TOKEN:
aud: https://vault.company.example
script:
- crunu run --env payments-api/production -- ./deploy.shHere is how the difference plays out for a token that leaks, say, through a log line:
| Stored token only | Token with OIDC binding | |
|---|---|---|
| Used from a laptop | Works until revoked | Refused: no valid JWT from the CI provider |
| Used from another repository's pipeline | Works | Refused: repository claim does not match |
| Used from a feature branch | Works | Refused if the rule names main |
| Same JWT used twice | Not applicable | Refused as a replay |
For example, Kiteline's token github-actions-web-app reads only web-app / staging and is bound to the repository kiteline/web-app on branch main. Kiteline is a fictional company. If that token were copied out of CI, it would not sign in anywhere else.
A network-restricted environment can go one step further: it can accept CI access only from tokens that have an OIDC binding. See network rules.
Write the rules tightly
An OIDC binding is only as precise as its rules. A few pitfalls are common across providers.
- Repository without branch. A rule that checks only the repository accepts any branch that can run a workflow, including one opened by anyone with push access. For production, name the branch.
- Branch without environment. On GitHub, a deployment environment can have protection rules, such as required reviewers or a list of allowed branches (GitHub Docs). Binding production to the environment claim makes the token depend on that protection too.
- Shared audiences. Bitbucket's default audience is per workspace, so every service that trusts it accepts the same token. Use your Crunu server address as a custom audience in the step.
When a token leaks anyway
Revoke it under Machines and tokens. Its next sign-in is refused; a pipeline step that is already running finishes with whatever it fetched. Then rotate the keys of the environments it received, and reissue the values in them at the providers. Rotation protects future versions; values the token already fetched remain valid until you replace them.
Every fetch by every token is recorded, so the audit log shows when the token was used and from which address. That record is what tells you how far the leak went.
A short checklist
More detail: CI/CD feature overview, OIDC binding and service tokens.
Sources
- OWASP Secrets Management Cheat Sheet, checked October 27, 2026.
- OpenID Connect Core 1.0, checked October 27, 2026.
- OpenID Connect reference, GitHub Docs, checked October 27, 2026.
- Deployments and environments, GitHub Docs, checked October 27, 2026.
- GitLab CI/CD ID tokens, GitLab Docs, checked October 27, 2026.
- Integrate Pipelines with resource servers using OIDC, Atlassian Support, checked October 27, 2026.