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 # 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" # 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.
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.
Related Jump to heading
- Protecting & Rotating Signing Keys โ the parent topic.
- Signing Release Tags from a Pipeline โ the workflow that uses this key.
- Limiting Workflow Permissions per Job โ the token-side counterpart to scoping the key.
- Signing Commits Inside Ephemeral Containers โ the same pattern when the job runs in a container.