Protecting Sensitive Data Across Pipeline Stages
Multi-stage pipelines pass secrets between jobs. Here is how we handle encrypted values and one-time tokens so credentials stay safe from build to deploy.
The Problem With Secrets in Pipelines
Multi-stage pipelines have a secret distribution problem.
A build job reads a database credential. A test job needs the same credential. A deploy job needs a bootstrap token to initialize new infrastructure. Each stage needs something sensitive.
Most teams solve this by pasting credentials into CI environment variables and letting the runner inject them at runtime. The approach works, but the exposure surface grows with every stage. Environment variables show up in logs, in the pipeline configuration UI, and in error output. They persist across runs. A credential stored this way in January is still there in July, and often nobody notices.
When a pipeline runs across three or four stages, the same secret has touched four separate execution contexts and is potentially logged in all of them.
Two Tools for Two Different Problems
KeyHarbour addresses this with two mechanisms that work together.
The first is client-side encryption. A secret is encrypted on your machine before it is sent to the server. The server stores only ciphertext. Each pipeline stage decrypts the value locally using a key that never leaves your CI environment.
The second is one-time-only values. When a key is marked as one-time-only, KeyHarbour deletes the key as soon as the first read completes. A second read returns a 404. For tokens meant to be used exactly once, this is an operational guarantee, not just a naming convention.
Setting Up
Generate a 256-bit AES key and store the hex string in your CI provider as a protected variable named KH_ENCRYPTION_KEY. This is the only secret that needs to live in your CI configuration.
openssl rand -hex 32Store your database credential encrypted using the kh CLI:
export KH_ENCRYPTION_KEY=<your-hex-key>
kh kv set DATABASE_URL "postgres://user:pass@db.prod.example.com/app" \
--workspace <workspace-uuid> \
--encryptStore the bootstrap token as a one-time-only encrypted value:
kh kv set BOOTSTRAP_ADMIN_TOKEN "eyJhbGc..." \
--workspace <workspace-uuid> \
--encrypt \
--one-time-onlyThe server now holds two encrypted blobs. Neither value is readable without KH_ENCRYPTION_KEY.
The Three-Stage Pipeline
Here is a concrete GitHub Actions workflow that puts both mechanisms to work.
name: Deploy
on:
push:
branches: [main]
env:
KH_TOKEN: ${{ secrets.KH_TOKEN }}
KH_ENDPOINT: ${{ secrets.KH_ENDPOINT }}
KH_ENCRYPTION_KEY: ${{ secrets.KH_ENCRYPTION_KEY }}
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kh CLI
run: go install github.com/key-harbour/kh/cmd/kh@latest
- name: Run integration tests
run: |
export DATABASE_URL=$(kh kv get DATABASE_URL --encrypt)
pytest tests/integration/
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Install kh CLI
run: go install github.com/key-harbour/kh/cmd/kh@latest
- name: Apply Terraform
run: |
export DATABASE_URL=$(kh kv get DATABASE_URL --encrypt)
terraform apply -auto-approve
bootstrap:
name: Bootstrap
runs-on: ubuntu-latest
needs: deploy
steps:
- uses: actions/checkout@v4
- name: Install kh CLI
run: go install github.com/key-harbour/kh/cmd/kh@latest
- name: Initialize environment
run: |
ADMIN_TOKEN=$(kh kv get BOOTSTRAP_ADMIN_TOKEN --encrypt)
./scripts/initialize-environment.sh "$ADMIN_TOKEN"KH_TOKEN, KH_ENDPOINT, and KH_ENCRYPTION_KEY are set once under Settings > Secrets and variables > Actions in the GitHub repository. No credentials appear in the YAML.
Stage 1: Integration Tests
The test job retrieves the database credential and decrypts it locally. The plain-text value exists only in memory for the duration of the job. Nothing is written to disk or to the pipeline log.
Stage 2: Deploy
The deploy job retrieves the same credential independently. Both stages share the encryption key through the CI variable, not through each other. A failure in the test stage does not affect the deploy stage’s ability to get its credential.
Stage 3: Bootstrap
The bootstrap job reads BOOTSTRAP_ADMIN_TOKEN once. As soon as the read completes, KeyHarbour deletes the key. If the bootstrap step fails and a developer retries the job, the second read returns a 404. This forces a deliberate decision: re-create the token, confirm the first run did not partially succeed, then retry.
This is the intended behavior. A token that is meant to be used once should not survive a retry loop silently.
What This Changes
Credentials in CI pipelines are usually a shared risk. Everyone with access to the pipeline configuration sees the variable. Logs retain it across runs. A mistake in a script prints it.
With encrypted values and one-time-only tokens:
- The server stores only ciphertext. A server-side breach does not expose your credentials.
- Tokens meant for one use are destroyed on that use. Replay is not possible.
- Each stage retrieves exactly what it needs, decrypts locally, and holds the plain-text value only for the duration of the job.
- A failed retry on a one-time-only token signals that the first attempt ran at least partially.
The pipeline YAML stays clean. The only value in your CI provider’s secret store is the encryption key.
Get Started
# Authenticate
kh auth login --token <api-token> --endpoint https://app.keyharbour.ca/api/v2
# Generate and export your encryption key
export KH_ENCRYPTION_KEY=$(openssl rand -hex 32)
# Store an encrypted secret
kh kv set MY_SECRET "value" --workspace <uuid> --encrypt
# Retrieve and decrypt
kh kv get MY_SECRET --encryptThe full CLI reference is at docs.keyharbour.ca. Reach out at info@keyharbour.ca with questions.