Skip to content

CI jobs without a stored key

A deploy job that needs a secret usually gets a long-lived key stored in the CI system's own secret store: one more copy of a credential to rotate and to leak. The alternative: the CI platform gives every job a short-lived signed token that says which repository and branch the job runs for, and SecretHive is told to trust tokens from that platform. Nothing is stored for the job anywhere.

Status 2026-10-10: run end to end from a job on a Forgejo instance (16.0). The GitHub and GitLab examples follow those platforms' documentation and have not been run by us.

How it works

  1. A super admin adds the CI platform as a provider on Admin → Providers, with sign-in off and "Accept tokens this issuer signs on the API" on.
  2. The job asks its platform for a token for SecretHive (the audience) and sends it as Authorization: Bearer <token>.
  3. SecretHive checks the signature against the issuer's own published keys, the audience, that the token lives a day at most, and any claims the provider requires.
  4. What the job may do comes from groups, exactly as for people. The provider's groups claim is set to the claim that names the repository. A SecretHive group is linked to each repository that may read, and that group is given a role in a vault.

A token that passes every check and matches no linked group is a valid sign-in that sees nothing. A token is never an administrator. Every refusal is a security event that says why (wrong audience, expired, unknown issuer, a required claim missing) and holds nothing of the token. Reads are on record under the name the token gives itself, with the provider's name in front.

Taking a repository's group out of a vault ends its access at the next request. There is nothing to revoke, because nothing was handed out.

What to fill in

Field For a CI platform
Issuer The address the platform signs tokens as (table below)
Sign-in on Off. No client id or secret is needed
Accept tokens on the API On
Audiences What a token must be for. Use the server's own address and have the job ask for exactly that. Required: without it any token the platform signs for anything would do
Groups claim repository (Forgejo, GitHub) or project_path (GitLab)
Required claims One name=value per line; a token must carry every one, and a name repeated allows several values. A value may end in *: ref=refs/tags/docker-* matches every tag that starts with docker-. Narrow it to your own organisation and, for anything that deploys, to the branch or tags it deploys from
Platform Issuer Narrowing claims worth requiring
Forgejo https://<forge>/api/actions repository_owner=<org>, ref=refs/heads/main (see below: not ref_protected)
GitHub Actions https://token.actions.githubusercontent.com repository_owner=<org>; ref=refs/heads/main or an environment
GitLab CI the GitLab instance's own address namespace_path=<group>, ref_protected=true

Then, for each repository that may read: a group (Admin → Groups), on it a link under "Linked groups at a provider" with the repository as the token names it (owner/name), and on the vault the role Secrets User for that group. The same through the API:

curl -fsS -X PUT -H @auth.hdr -H 'Content-Type: application/json' $S/groups/deploy-shop \
     -d '{"description":"the shop repository"}'
curl -fsS -X PUT -H @auth.hdr -H 'Content-Type: application/json' $S/groups/deploy-shop/provider-links \
     -d '{"provider":"forge","group":"acme/shop"}'
curl -fsS -X PUT -H @auth.hdr -H 'Content-Type: application/json' $S/vaults/shop-prod/access \
     -d '{"group":"deploy-shop","role":"SecretsUser"}'

In a job

Forgejo Actions

jobs:
  deploy:
    runs-on: docker
    enable-openid-connect: true
    steps:
      - name: Read a secret from SecretHive
        run: |
          token=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
                    "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://vault.example.org" | jq -r .value)
          curl -fsS -H "Authorization: Bearer $token" \
            https://vault.example.org/api/v1/vaults/shop-prod/secrets/deploy-token/value \
            | jq -r .value > deploy-token

GitHub Actions

permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Read a secret from SecretHive
        run: |
          token=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
                    "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://vault.example.org" | jq -r .value)
          curl -fsS -H "Authorization: Bearer $token" \
            https://vault.example.org/api/v1/vaults/<vault>/secrets/<name>/value | jq -r .value > secret

GitLab CI

deploy:
  id_tokens:
    SECRETHIVE_TOKEN:
      aud: https://vault.example.org
  script:
    - curl -fsS -H "Authorization: Bearer $SECRETHIVE_TOKEN"
        https://vault.example.org/api/v1/vaults/<vault>/secrets/<name>/value | jq -r .value > secret

In every case the value goes to a file or straight into the tool that needs it, never into the job's log. A CI platform masks its own stored secrets in the log; it does not know this value is one.

What the real run showed

A job in a private repository read its one secret (compared by hash with the server's), saw only its own vault, got "no such vault" for another one, and was refused when it tried to change the value. Three things the platform's documentation did not say:

  • The token's subject carries the forge's own ids for the owner and the repository, while repository is the plain name. The audit trail therefore names a repository in a way a renamed or re-created one cannot imitate.
  • repository is matched by name. A repository deleted and made again under the same name by the same owner matches the same link. Where that matters, set the groups claim to repository_id and link the number.
  • ref_protected is "false" in every Forgejo job token, on a protected main and on a protected tag alike (measured with Forgejo 16.0.3). A provider that requires ref_protected=true lets no Forgejo job in: require the ref instead.

What to be careful about

  • The audience is the lock. Every repository on a shared platform can get a token; only one asked for this audience is accepted, and only a repository in a linked group is given anything.
  • Require a protected branch for anything that deploys. Otherwise whoever can push a branch to a linked repository can run a job that reads what the repository may read. Pull requests from forks are the same question; platforms differ in whether such a run can get a token at all, so do not rely on it.
  • A linked repository is as trusted as its workflow files. Whoever can change the workflow on a protected branch can read what the group reads. That is the same trust a stored key would need, without the key.
  • A job has a pace like a key: 300 requests a minute under the name its token gives it, then 429 until the minute is over.
  • A vault limited to a network refuses a job that comes from elsewhere, token or not: for those, the runner has to be on that network.

Branches for one repository, release tags for another

Required claims belong to a provider, so one provider gives every repository linked at it the same rule. To let image builds on release tags read the registry token while a deploy job keeps reading its key from main only, add a second provider for the same issuer and audience, with the tag rule, and link only the image repositories at it:

Provider Required claims Linked
forge ref=refs/heads/main every repository whose jobs run on main
forge-tags repository_owner=apps, ref=refs/tags/docker-*, ref=refs/tags/v* the image repositories only

A token is tried against each provider of its issuer and the first that accepts it decides its groups. A tag pushed to a repository that is linked only at forge is accepted by forge-tags as a token and opens nothing. Protect the release tags on the forge as well, so only the people meant to release can push one.