Protecting CI signing keys with environment secrets Jump to heading

When a pipeline signs commits or tags, its key is the most powerful secret in the repository: anything it signs passes your verification gates. Stored as an ordinary repository secret, it is available to every workflow, on every branch, including workflows a contributor adds in a pull request that a maintainer later approves to run. Environment-scoped secrets narrow that dramatically. The key is released only to jobs that declare the environment, only on branches the environment allows, and optionally only after a human approves. This page sets that up and shows how to use the key inside a job without leaving it on disk. It belongs to protecting and rotating signing keys.

When to use this approach Jump to heading

  • A workflow signs release tags, version-bump commits or bot commits with a long-lived key.
  • That key is currently a repository-wide or organisation-wide secret.
  • You cannot yet use keyless signing โ€” if you can, keyless commit signing with Sigstore gitsign removes the stored key entirely.
  • You want an audit record of every job that used the key.

Step 1 โ€” Create a dedicated key for CI Jump to heading

Never reuse a personโ€™s key. A CI key has its own principal, its own registration and its own rotation schedule.

ssh-keygen -t ed25519 -N '' -C "signing:[email protected]:$(date +%Y-%m)" -f release-bot
# Register release-bot.pub as a signing key for the bot account, and add it to allowed_signers:
printf '[email protected] namespaces="git" %s\n' "$(cat release-bot.pub)"

The key has no passphrase because no human is present to type one. Its protection comes from where it is stored and who can retrieve it, which is the rest of this page.

Step 2 โ€” Put the key in a protected environment Jump to heading

Create an environment, restrict which branches can deploy to it, require a reviewer if signing should be approved, and store the private key as an environment secret.

gh api -X PUT "repos/$OWNER/$REPO/environments/release-signing" \
  -F 'deployment_branch_policy[protected_branches]=false' \
  -F 'deployment_branch_policy[custom_branch_policies]=true'
gh api -X POST "repos/$OWNER/$REPO/environments/release-signing/deployment-branch-policies" \
  -f name=main -f type=branch
gh secret set RELEASE_SIGNING_KEY --env release-signing < release-bot
shred -u release-bot
Repository secret against environment secretA repository secret is available to any workflow on any branch once it runs. An environment secret is released only to jobs that declare the environment, on branches the environment allows, and optionally after a reviewer approves the run.Repository secretEnvironment secretwhich workflowsall of themjobs naming the envwhich branchesanyallow-listhuman approvalnot possibleoptional reviewersaudit trailworkflow logsdeployment historythe environment turns 'any job can sign' into 'this job, on main, approved'
# Verification: the secret is listed under the environment, not the repository
gh secret list --env release-signing
gh secret list | grep -c RELEASE_SIGNING_KEY    # expect 0

Step 3 โ€” Declare the environment only in the signing job Jump to heading

Keep signing in a small, separate job that declares the environment. Build and test jobs never see the key, so a compromised test dependency cannot reach it.

jobs:
  build:
    runs-on: ubuntu-latest
    steps: [ { uses: actions/checkout@v4 }, { run: make build test } ]

  tag:
    needs: build
    runs-on: ubuntu-latest
    environment: release-signing        # the only job that can read the key
    permissions: { contents: write }
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: ./ci/sign-and-push-tag.sh "$VERSION"
        env:
          SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}

Step 4 โ€” Load the key into a short-lived agent, not a file Jump to heading

Writing the key to disk leaves it for any later step, cache or artefact upload to pick up. Load it straight into an agent that dies with the job.

#!/bin/sh
# ci/sign-and-push-tag.sh
set -eu
eval "$(ssh-agent -s)" >/dev/null
trap 'ssh-agent -k >/dev/null' EXIT
printf '%s\n' "$SIGNING_KEY" | ssh-add -t 600 - >/dev/null    # key lives 10 minutes, in memory
unset SIGNING_KEY

pub=$(ssh-add -L | head -1)
git config user.name  "release-bot"
git config user.email "[email protected]"
git config gpg.format ssh
git config user.signingKey "key::$pub"

git tag -s "v$1" -m "Release $1"
git verify-tag "v$1" 2>/dev/null || true    # local check needs allowed_signers; optional
git push origin "v$1"
The key's path through a signing jobThe environment releases the secret to the job only after branch rules and approval pass. The script pipes it straight into an in-memory agent with a ten-minute lifetime, signs the tag through the agent, and kills the agent when the script exits.environmentjobssh-agentgitsecret, after approvalssh-add -t 600 -tag -s with key:: pubkeysign requestsignaturessh-agent -k on exitthe private key is never written to the runner's filesystem
# Verification: no private key file was left on the runner
grep -rl 'BEGIN OPENSSH PRIVATE KEY' "$GITHUB_WORKSPACE" "$HOME" 2>/dev/null || echo "none found"

Step 5 โ€” Keep the key out of logs and caches Jump to heading

Secrets are masked in logs when printed verbatim, but a key transformed in any way โ€” base64-encoded, split across lines, written as part of an error โ€” escapes masking. Avoid every command that would echo it, and never let the signing job upload artefacts or save caches.

  tag:
    environment: release-signing
    steps:
      - uses: actions/checkout@v4
        with: { persist-credentials: false }
      # no actions/cache, no upload-artifact in this job

For the wider topic of secrets leaking through build output, see keeping secrets out of CI logs.

Narrowing who can reach the keyEach layer removes a class of workflow that could otherwise read the key: environment scoping removes unrelated workflows, the branch allow-list removes pull-request branches, required reviewers remove unattended runs, and a separate job removes the build's dependencies.who can still use the key after each layerEnvironment scopeonly jobs naming release-signingBranch allow-listonly runs on mainRequired revieweronly approved runsSeparate signing jobbuild dependencies never see itby the bottom layer, one small reviewed job is the only consumer

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can a pull request from a fork get the key? Jump to heading

Not if the environmentโ€™s branch policy allows only the release branch. Workflows triggered by pull requests run against the pull requestโ€™s ref, which is not on the allow-list, so the environment refuses to release the secret.

How often should a CI signing key be rotated? Jump to heading

More often than a personโ€™s key, because it is harder to know whether it leaked. Quarterly is a reasonable starting point; automate it so rotation is a scheduled workflow rather than a project. See rotating CI signing identities without breaking verification.

Does this work on other CI systems? Jump to heading

The idea transfers: GitLab has protected environments and protected variables, and most CI systems can scope secrets to protected branches. The specific API calls differ, but the layers โ€” scope, branch, approval, separate job โ€” are the same.