Secrets Injection for Preview Environments
The operational pain here is that every ephemeral preview environment needs real credentials — a database URL, an API key, a third-party token — but baking those into images or copying production secrets into short-lived namespaces is how leaks and blast-radius incidents happen. This page covers how to deliver secrets to previews using short-lived, OIDC-minted credentials scoped to a preview-only policy, injected at container boot, masked in logs, and expired on a TTL aligned to the preview lifetime. The goal is that a compromised preview credential is useless against production and useless minutes after the PR closes.
Prerequisites
How Short-Lived Secret Injection Works Under the Hood
The insecure default is a long-lived secret stored in CI: it never expires, it is copied into every job’s environment, and if the log redaction ever slips, it leaks permanently. OIDC-based injection replaces that stored secret with an on-demand exchange.
- Identity, not secret. The CI runner is issued a signed OIDC token describing who is running (repository, workflow, event, ref). No credential is stored — the token is minted fresh per job and expires in minutes.
- Exchange for a scoped credential. The runner presents that token to Vault or the cloud secret manager, which verifies the signature and the bound claims, then mints a short-lived credential limited to the preview policy.
- Boot-time injection. The preview container receives secrets at startup — as a mounted file or environment variables — never baked into the image layer. The image itself contains no secrets and is safe to cache and share.
- Scope and expiry. The preview credential can read only the preview scope, not production. Its TTL is set to just longer than the preview lifetime, so it dies when the environment is torn down.
This mirrors the discipline in synchronizing environment variables across stages: definitions are version-controlled, values come from a manager at runtime, and nothing sensitive is committed or baked in.
Step-by-Step Implementation
Step 1 — Configure OIDC trust and a preview-scoped role
# Vault: trust GitHub's OIDC issuer and bind a role to PR-triggered runs only.
vault write auth/jwt/config \
oidc_discovery_url="https://token.actions.githubusercontent.com" \
bound_issuer="https://token.actions.githubusercontent.com"
vault write auth/jwt/role/preview \
role_type="jwt" \
user_claim="sub" \
bound_audiences="https://github.com/acme" \
bound_claims='{"repository":"acme/app","event_name":"pull_request"}' \
token_policies="preview-read" \
token_ttl="2h"Verification: vault read auth/jwt/role/preview shows token_policies=[preview-read] and the PR-scoped bound claims.
Step 2 — Restrict the preview policy to the preview scope
# preview-read.hcl — readable ONLY under the preview path
path "secret/data/preview/*" {
capabilities = ["read"]
}
# No production paths. A leaked preview token cannot reach secret/data/production/*.Verification: With a preview token, vault kv get secret/production/app returns permission denied, while vault kv get secret/preview/app succeeds.
Step 3 — Exchange the OIDC token and inject at boot
# .github/workflows/preview-secrets.yml (excerpt)
permissions:
id-token: write # required to mint the OIDC token
contents: read
steps:
- name: Fetch preview secrets via OIDC
uses: hashicorp/vault-action@v3
with:
url: ${{ secrets.VAULT_ADDR }}
method: jwt
role: preview
# Values are masked in logs automatically by the action
secrets: |
secret/data/preview/app DATABASE_URL | DATABASE_URL ;
secret/data/preview/app API_KEY | API_KEY
- name: Inject into the preview at boot (not into the image)
run: |
kubectl -n preview-${PR_NUMBER} create secret generic app-secrets \
--from-literal=DATABASE_URL="$DATABASE_URL" \
--from-literal=API_KEY="$API_KEY" \
--dry-run=client -o yaml | kubectl apply -f -Verification: kubectl -n preview-${PR} get secret app-secrets exists, and the container image built earlier contains no secret layer (docker history shows no secret ARG).
Step 4 — Confirm masking and TTL expiry
# Masking: grep the job log for the raw value — it must appear only as ***
# TTL: the Vault token dies after 2h, so a leaked cred is useless post-teardown.
vault token lookup -format=json | jq '.data.ttl' # → seconds remaining, ≤ 7200Verification: The credential’s remaining TTL is at most the configured 2 hours, and no raw secret value appears in any log line.
Configuration Reference
| Option | Type | Default | Effect |
|---|---|---|---|
bound_claims |
object | — | Restricts which repo/event can assume the role; scope to pull_request for previews |
token_ttl |
duration | policy default | Credential lifetime; set just above preview lifetime so it expires on teardown |
token_policies |
list | — | Must grant read to the preview scope only, never production |
id-token permission |
enum | none | Must be write for the workflow to mint an OIDC token |
| Injection method | enum | env/secret | Boot-time mount or K8s Secret; never a baked image layer |
| Log masking | boolean | on | Keep enabled so fetched values render as *** |
Integration with Upstream and Downstream Topics
Secrets injection is a sibling of the other preview environment concerns:
- Upstream — provisioning. Automated preview deployments create the namespace this secret is written into.
- Sibling — variables. Synchronizing environment variables across stages governs the non-secret config that accompanies these credentials.
- Sibling — routing. Ephemeral preview URL management exposes the app that consumes these secrets.
- Downstream — parity. Environment parity validation gates verify the injected secret keys match production even though the values differ.
Performance and Cost Impact
| Activity | Long-lived CI secret | OIDC short-lived | Benefit |
|---|---|---|---|
| Credential lifetime | until manually rotated | minutes to hours | Bounded leak window |
| Blast radius of a leak | whatever the secret grants | preview scope only | Production stays isolated |
| OIDC exchange latency | 0 | 1–3 s per job | Small, network-bound cost |
| Rotation effort | manual, error-prone | automatic on TTL | No standing rotation toil |
| Secret in image layers | risk if mishandled | never (boot injection) | Images safe to cache and share |
The cost is a 1–3 second token exchange per job; the benefit is that no long-lived production-capable secret ever sits on a runner or in an image.
Troubleshooting
Error: permission denied fetching preview secrets
Cause: The OIDC token’s claims do not match the role’s bound_claims — commonly the workflow runs on an event other than pull_request, or from a fork whose repository claim differs.
Fix: Align bound_claims with the actual event, and decide deliberately whether fork PRs may fetch secrets (usually not). Inspect the token claims in the job log to compare against the role.
Error: id-token request fails with 403
Cause: The workflow lacks permissions: id-token: write, so no OIDC token can be minted.
Fix: Add the permission at the job or workflow level. It is not granted by default.
Symptom: Secret value visible in logs
Cause: A value was echoed or written to a file whose contents were later printed, bypassing the action’s automatic masking.
Fix: Never echo secret values; reference them only as environment variables passed directly to the consuming command. Add a log scanner in CI that greps for known secret prefixes as a backstop.
Symptom: Preview cannot connect to its database
Cause: The preview credential points at production (denied) or the preview database URL was not populated because the fetch step ran after the deploy.
Fix: Ensure the preview scope contains a working preview DATABASE_URL, and order the secret fetch before the deploy step. Pair with database mocking and seeding so previews use isolated data.
Frequently Asked Questions
Why use OIDC instead of storing secrets in CI?
A stored CI secret is long-lived, copied into every job, and leaks permanently if redaction ever fails. OIDC stores no secret: the runner is issued a short-lived, cryptographically signed identity token scoped to the repository and event, and exchanges it for a credential minted on demand. That credential expires in minutes to hours and is bound to a narrow policy, so there is nothing durable to steal and any leak has a small, self-closing window.
Should preview environments use production secrets?
No. Previews must read a separate preview scope with their own database, API keys, and third-party sandbox tokens. Two rules follow: a leaked preview credential must never grant production access (enforced by the scoped policy in Step 2), and a preview must never write to production systems. This isolation is what lets you run hundreds of untrusted per-PR environments safely.
How long should preview credentials live?
Set the TTL just longer than the preview’s expected lifetime — often two to four hours. The credential then expires automatically around the time the environment is torn down or goes idle, so even an undetected leak is useless shortly after. Avoid the temptation of long TTLs “to be safe”: a long TTL is precisely what turns a minor leak into a lasting one.
Choosing Between Injection Points
Secrets can enter a preview environment at three moments, and the choice determines both how long they live and how much of the system has to be trusted.
At build time. The value is baked into the artifact. This is the least flexible option and the most dangerous for a preview, because the artifact is then environment-specific and any secret in it is permanent — it lives as long as the image, in whatever registry holds it, readable by anyone who can pull. The only values that belong here are ones that are not secret at all, such as a public analytics key. If a value must differ between preview and production, that alone is sufficient reason to keep it out of the build.
At deploy time. The pipeline fetches short-lived credentials, renders them into the platform’s own secret store, and the workload reads them from there. This is the pragmatic default for most teams: the secret exists in the environment for the environment’s lifetime, the fetch is auditable, and nothing sensitive touches the artifact. The cost is that the secret outlives the deploy job, so a compromised preview environment yields whatever it holds until teardown.
At run time. The workload authenticates itself to the secret store on startup and holds a lease it renews. Nothing is ever written to a secret store the pipeline controls, and revoking access is immediate rather than waiting for a redeploy. This is the strongest option and the most work: it requires the application to carry a client, handle renewal failure, and have a workload identity of its own — which for a preview environment means that identity has to be created and destroyed alongside it.
Most teams end up mixing the second and third: deploy-time injection for configuration the application reads once at boot, and run-time fetching for anything with a genuine revocation requirement, such as a database credential or a payment provider key.
Scoping rules that hold under pressure
Whichever injection point you choose, three scoping rules do most of the work. First, a preview role reads only preview paths — never a production path, even a read-only one, because a preview environment executing untrusted pull-request code is the least trustworthy compute you operate. Second, the lease is the shortest that completes the job, which for a deploy is usually well under fifteen minutes. Third, the identity is bound to the pull-request context rather than to the repository as a whole, so a workflow on another branch cannot assume it.
The rules are easy to state and easy to erode. The usual erosion path is a debugging session: someone widens the preview role to reach a production path “just to check something”, the check works, and the widening is never reverted because nothing fails afterwards. Reviewing the trust policy on a schedule — quarterly is enough — catches that class of drift, and takes about ten minutes when the policies are small enough to read in one sitting.
Reviewing the Trust Policies on a Schedule
Scoping rules erode quietly. The usual path is a debugging session: someone widens a preview role to reach a production path in order to check something, the check works, and the widening is never reverted because nothing subsequently fails. Six months later the preview role — assumable by untrusted pull-request code — can read production secrets, and nothing in the system will ever point this out.
A quarterly read-through of the policies catches it, and takes about ten minutes when the policies are small enough to read in one sitting. Three questions are enough: can any preview identity reach a production path, is any lease longer than the job that uses it, and does any trust policy accept a subject pattern broader than a single repository and ref.
Keeping the policies small is what keeps the review cheap, which is the practical argument for one role per environment and per privilege level rather than one parameterised role that tries to serve everything.
Finally, test the negative case as part of setup rather than assuming it. Confirm that a preview identity requesting a production path is refused, and that the refusal appears in the audit log. A control nobody has watched fail has not actually been verified, and this one takes a single command to exercise while the context is fresh.
Related
- Injecting Vault Secrets into Preview Deploys with OIDC — the complete, copy-paste OIDC-to-Vault workflow.
- Synchronizing Environment Variables Across Stages — the non-secret configuration companion to injected credentials.
- Ephemeral Preview URL Management and Routing — exposing the app that consumes these secrets.
- Environment Parity Validation Gates — verifying injected secret keys match production.