Docs menu and this page

On this page

For agents: /docs/ci.md is this page as Markdown.

CI: hand a fresh credential to a server or a later job

A job creates with its own OIDC identity, so the pipeline holds no ckrets key. The start, the keys and the audiences are in For agents; the full schemas in /openapi.json.

The job that mints the credential creates a token bound to the recipient with its own OIDC identity (no ckrets key stored), a short TTL and one reveal, and revokes it if nobody collects. The recipient pulls it on its own schedule. Here the recipient is a server that holds an Ed25519 key it never shares (/docs/signing.md#making-the-keys), named as a publicKey audience; the server's public JWK is not a secret and can sit in the workflow file.

yaml
permissions: { id-token: write, contents: read }
steps:
  - name: Hand the new DB password to the server
    shell: bash          # -eo pipefail: any failing command below fails the step
    env:
      NEW_PASSWORD: ${{ steps.rotate.outputs.password }}
      SERVER_JWK: '{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}'
    run: |
      CKRETS=${CKRETS:-https://ckrets.ai}
      OIDC=$(curl -sS --fail-with-body -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
        "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://ckrets.ai" | jq -er .value)
      jq -n --argjson jwk "$SERVER_JWK" --arg p "db rotation run $GITHUB_RUN_ID" \
        '{value:$ENV.NEW_PASSWORD, ttl:"15m", reveals:1, audience:{type:"publicKey", jwk:$jwk}, purpose:$p}' |
      curl -sS --fail-with-body -X POST "$CKRETS/v1/secrets" \
        -H "authorization: Bearer $OIDC" -H 'content-type: application/json' \
        -H "idempotency-key: $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-db" --data-binary @- |
      jq -er '.token // empty' > token.txt

jq reads the password from the environment ($ENV.NEW_PASSWORD), not from an argument, so it never appears in argv or the log. A failed create fails the step instead of writing the string null to token.txt: --fail-with-body makes curl exit non-zero on any non-2xx answer (and still prints the problem body), jq -e exits non-zero when there is no token, and shell: bash turns on pipefail so either one stops the step. The idempotency key includes the run attempt, because a rerun would otherwise get back a token that may already be spent. Only POST /v1/secrets reads an idempotency key (POST /v1/keys with one still mints a new key). It is scoped to you: your address when anonymous (an IPv6 caller by its /64), so callers behind one NAT or shared egress address share one scope; else your key; or for a CI job its issuer and repository, so every job of one repository shares one scope (the credential is checked first). In a shared scope make the key unguessable (a UUID or a run id), never a counter. It is bound to the request: value, ttl, reveals, audience, whether a passphrase is set, clientEncrypted and wrappedKey. It is recorded only when a secret is made: a create refused with 400, 413, 428 or 429 leaves it unused. For 24 hours from the create that first used it (to the whole second), the same key and the same request return the first answer exactly as it was made (201, Idempotent-Replay: true, nothing counted): if only purpose or the passphrase's value changed, you get the first secret with its first purpose and passphrase. The same key with a different request is 409 idempotency-mismatch: it makes nothing but counts as one create, and after three different requests the key is retired (409 idempotency-key-retired for every later request with it, the right one included), so nobody can find your value by trying requests under your key; another caller's key never returns your answer. Two creates sent at once with the same key make one secret: the second answers 409 idempotency-in-progress with Retry-After: 1, and its retry gets the first answer. Without the header, every create makes a new secret. After a 503 with outcome: "unknown" the key stays reserved for 30 seconds: a retry with it answers 409 idempotency-in-progress until then, and after that makes a new secret (the first, if it was made, expires unrevealed: nobody holds its token). After a bare 503 from the platform (no body) the create may have finished: a retry with the same key then gets its answer (Idempotent-Replay: true). The server reveals with CKRETS_SIGNING_KEY=/etc/ckrets/server.jwk npx -y @ckrets/helper@0.9.0 reveal "$TOKEN" --env DB_PASSWORD -- ./reload.sh, or with the Python signer. Its clock must be within 60 seconds (NTP), because the signature carries created.

If a later job in the same repository is the recipient instead, bind to its identity: "audience":{"type":"oidc","issuer":"https://token.actions.githubusercontent.com","claims":{"repository":"acme/api","job_workflow_ref":"acme/api/.github/workflows/deploy.yml@refs/heads/main"}}, and that job reveals with npx -y @ckrets/helper@0.9.0 reveal "$TOKEN" --env DB_PASSWORD -- ./migrate.sh under permissions: id-token: write. The ckrets token is safe to pass between jobs as an output here only because its audience binds it to that job; a token without an audience is a bearer secret and must never sit in a log or an output.

One job to a later job, complete (GitHub Actions)

The token is safe as a job output only because its audience binds it to the deploy workflow. The later job reveals with plain curl and its own OIDC token, so this works before the helper is published.

yaml
jobs:
  mint:
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: read }
    outputs: { token: ${{ steps.hand.outputs.token }} }
    steps:
      - id: hand
        shell: bash
        env: { NEW_PASSWORD: ${{ steps.rotate.outputs.password }} }
        run: |
          CKRETS=${CKRETS:-https://ckrets.ai}
          OIDC=$(curl -sS --fail-with-body -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://ckrets.ai" | jq -er .value)
          jq -n --arg wf "$GITHUB_REPOSITORY/.github/workflows/deploy.yml@$GITHUB_REF" \
            '{value:$ENV.NEW_PASSWORD, ttl:"15m", reveals:1, purpose:"db password for deploy",
              audience:{type:"oidc", issuer:"https://token.actions.githubusercontent.com",
                        claims:{repository:$ENV.GITHUB_REPOSITORY, job_workflow_ref:$wf}}}' |
          curl -sS --fail-with-body -X POST "$CKRETS/v1/secrets" -H "authorization: Bearer $OIDC" \
            -H 'content-type: application/json' -H "idempotency-key: $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-db" --data-binary @- |
          jq -er '"token=" + .token' >> "$GITHUB_OUTPUT"
  deploy:
    needs: mint
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: read }
    steps:
      - shell: bash
        env: { TOKEN: ${{ needs.mint.outputs.token }} }
        run: |
          CKRETS=${CKRETS:-https://ckrets.ai}
          OIDC=$(curl -sS --fail-with-body -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://ckrets.ai" | jq -er .value)
          DB_PASSWORD=$(curl -sS --fail-with-body -H "authorization: Bearer $OIDC" "$CKRETS/v1/secrets/$TOKEN" | jq -er .value)
          echo "::add-mask::$DB_PASSWORD"        # the runner hides it if anything prints it later
          export DB_PASSWORD && ./migrate.sh

shell: bash runs with -eo pipefail, so a refused reveal (--fail-with-body) or a missing value (jq -e) stops the job before ./migrate.sh runs. The deploy job's token matches job_workflow_ref, because both jobs are in deploy.yml on the same ref; a job from any other workflow or branch gets 403 audience-mismatch.

GitLab CI and CircleCI, with curl

The helper's OIDC proof is GitHub's. On GitLab and CircleCI, reveal with curl and the job's own token; name the job by sub in the audience.

GitLab (.gitlab-ci.yml), audience {"type":"oidc","issuer":"https://gitlab.com","claims":{"sub":"project_path:acme/api:ref_type:branch:ref:main"}}:

yaml
deploy:
  id_tokens:
    CKRETS_OIDC: { aud: https://ckrets.ai }
  script:
    - set -eo pipefail
    - DB_PASSWORD=$(curl -sS --fail-with-body -H "authorization: Bearer $CKRETS_OIDC" "${CKRETS:-https://ckrets.ai}/v1/secrets/$TOKEN" | jq -er .value)
    - export DB_PASSWORD && ./migrate.sh

CircleCI, audience {"type":"oidc","issuer":"https://oidc.circleci.com/org/<org-id>","claims":{"sub":"org/<org-id>/project/<project-id>/user/<user-id>"}} (CircleCI's sub includes the user who started the pipeline, so another user's run is refused):

yaml
steps:
  - run: |
      set -eo pipefail
      OIDC=$(circleci run oidc get --claims '{"aud":"https://ckrets.ai"}')
      DB_PASSWORD=$(curl -sS --fail-with-body -H "authorization: Bearer $OIDC" "${CKRETS:-https://ckrets.ai}/v1/secrets/$TOKEN" | jq -er .value)
      export DB_PASSWORD && ./migrate.sh

To keep ckrets from ever seeing the password, encrypt in the job before the create (/docs/client-encryption.md): with an encJwk in the server's audience, wrap the key with HPKE; or use fragment mode and pass the #k= part to the receiving server alongside the token (never to ckrets: the key in the fragment must not reach us).