Skip to content
CI/CD7 min read

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

Example. Kiteline is a fictional company.
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:

YAML
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 test

The 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:

INI
# /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/server

The 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: write permission. Its claims include repository, ref and, when the job uses one, environment (GitHub Docs).
  • GitLab CI issues tokens you declare with the id_tokens keyword, each with its own aud. Claims include project_path, ref, ref_type and environment. 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 oidc in 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:

YAML
    permissions:
      id-token: write
      contents: read

On GitLab CI, you declare the token with your Crunu server address as the audience:

YAML
deploy:
  id_tokens:
    CRUNU_ID_TOKEN:
      aud: https://vault.company.example
  script:
    - crunu run --env payments-api/production -- ./deploy.sh

Here is how the difference plays out for a token that leaks, say, through a log line:

Stored token onlyToken with OIDC binding
Used from a laptopWorks until revokedRefused: no valid JWT from the CI provider
Used from another repository's pipelineWorksRefused: repository claim does not match
Used from a feature branchWorksRefused if the rule names main
Same JWT used twiceNot applicableRefused 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

Terms in this post