Secrets handling in CI pipelines
CI pipelines routinely need credentials: deploy keys, package registry tokens, cloud access, signing material, or API keys. The hard part is not storing a value somewhere. The hard part is making sure the value is available only to the jobs that need it, does not leak through logs, artifacts, caches, or debug output, and can be replaced quickly when something goes wrong.
Good CI secrets handling is mostly operational discipline. Keep secrets out of source control, scope access tightly, prefer short-lived credentials, review workflow changes as security-sensitive code, and treat every place a job can print or persist data as a potential leak path. The goal is not a perfect vault. The goal is to reduce exposure at every step so a single mistake does not become a production incident.
Store secrets outside the repository and control who can use them
Do not hardcode credentials in workflow files, scripts, committed configuration, container build contexts, or test fixtures. CI platforms provide secret stores for a reason: values are managed separately from code and injected at runtime, with access controls that are different from normal repository content.
In GitHub Actions, secrets can be created at the repository, environment, or organization level. Workflows access them through the secrets context, for example ${{ secrets.MY_TOKEN }}. GitHub documents important boundaries that affect workflow design. Secrets are not passed to the runner when a workflow is triggered from a forked repository. Secrets are not available to workflows triggered by Dependabot events. Reusable workflows do not receive secrets automatically; the calling workflow must pass them explicitly. These rules are useful guardrails, but they also mean that pull request automation needs careful design when it must run untrusted code.
GitLab CI/CD separates values defined in .gitlab-ci.yml from sensitive values stored in CI/CD settings. GitLab recommends keeping non-sensitive configuration in the YAML file and storing secrets or keys in the UI, where access can be controlled. GitLab variables can be configured as variable or file type, and settings such as masking, hiding, and protection change how and where a value is available. Hiding and masking can reduce accidental exposure, but they are not substitutes for access control. Masking depends on matching the exact value, so it can fail when a value is transformed; a hidden value may still be used by pipelines.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy.sh
The example shows two deliberate choices. The secret is scoped to the deployment job rather than exposed broadly, and it is passed as an environment variable to the command that needs it. A broad env: block at the workflow or job level can be convenient, but it also gives every step in that scope access to the value.
Treat logs, artifacts, caches, and debug output as hostile to secrets
Accidental exposure is the most common CI secret leak. A command prints an environment variable, a debug trace includes a token, a decrypted file is uploaded as an artifact, or a tool writes sensitive state into a directory that is later cached or bundled. The CI system may help, but it cannot infer every sensitive value from context.
GitHub Actions automatically redacts known secret values in logs when it can match them exactly. GitHub's guidance is explicit that automatic redaction is not guaranteed. Sensitive values that are not stored as GitHub secrets should be masked with ::add-mask::VALUE. Structured data should not be used as a secret because redaction relies heavily on exact matching. If a workflow generates another sensitive value, such as a signed token derived from a secret, that derived value should also be registered as a secret so it can be redacted if it appears in logs. If a secret appears in a workflow run log, the practical response is to delete the log and rotate the secret.
- name: Create short-lived token
id: token
run: |
TOKEN="$(./scripts/mint-token.sh)"
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
Large-secret workarounds deserve the same caution. GitHub documents patterns for encrypting files with gpg and storing the passphrase as a secret, and for storing Base64-encoded binary blobs as secrets. These patterns solve storage or format problems, not exposure problems. A workflow that prints the decrypted file, decodes bytes to standard output, or leaves sensitive material in a workspace can still leak it. GitHub also warns that Base64 is encoding, not encryption.
Caches and build outputs are a separate class of risk. A file that disappears when an ephemeral runner ends is one thing; a file written into a shared cache, artifact, or reusable workspace is another. Cache contents can outlive the job that created them and may be readable in contexts the original author did not anticipate. A safe default is to keep secret-bearing jobs away from cache-writing jobs, and to avoid caching whole build directories when a job has access to production credentials.
Command lines are easy to overlook. Avoid passing secrets as command-line arguments when a tool can read them from environment variables, standard input, or a file with restricted permissions. Command lines may be visible to other processes or captured by audit mechanisms. When you must use environment variables in shell commands, quote them so special characters do not change behavior.
Limit blast radius with least privilege and trusted contexts
A secret is only as safe as the identity and workflow that can use it. Give CI jobs the minimum permissions needed for their task, separate untrusted code execution from trusted credential access, and review workflow definitions as carefully as application code.
In GitHub Actions, the GITHUB_TOKEN is a built-in credential used to access repository resources during a workflow run. GitHub recommends making sure it has the minimum required permissions and setting the default permission to read access for repository contents where practical, then increasing permissions only for jobs that need more. Environment secrets, required reviewers, and deployment protection rules can limit where sensitive credentials are available. A production deployment secret should usually live in a protected environment, not in a repository-wide secret available to every workflow.
Privileged workflow triggers are a common source of trouble. GitHub's secure-use guidance warns that pull_request_target and workflow_run can run with elevated context and, if combined with untrusted code checkout, can expose a repository to compromise. The practical rule is simple: do not let code from an untrusted pull request run in a context that has access to secrets or write permissions. Fork pull requests are especially sensitive because they often need CI feedback but should not automatically gain access to production credentials.
GitLab provides similar controls through protected variables, protected branches and tags, environment scoping, and role-based access. The same design principle applies across platforms: a job that runs tests on untrusted changes should not be the same job that can publish artifacts, deploy infrastructure, or read long-lived production secrets.
Third-party actions and reusable workflow components also affect secret safety. A compromised or poorly maintained action runs inside your workflow with access to the environment you give it. Pinning dependencies, reviewing action source, preferring well-maintained publishers, and minimizing the secrets passed to third-party steps all reduce risk. A dependency that only needs a read token should not receive a deployment credential "just in case."
Prefer short-lived credentials over static secrets
A stored CI secret is still a long-lived credential unless you design around it. When the target system supports it, prefer short-lived tokens issued at job runtime. Short-lived credentials reduce the value of an exposed token because the window for abuse is smaller and rotation can happen automatically.
GitHub's OpenID Connect (OIDC) integration is the clearest example in GitHub Actions. Instead of storing cloud credentials as GitHub secrets, a workflow can request an OIDC token from GitHub and exchange it with a cloud provider for temporary access. GitHub describes the benefits as avoiding duplicated cloud secrets, improving authentication and authorization management, and using credentials that are issued for a job and then expire. The tradeoff is that you must configure the trust relationship and conditions carefully on the cloud side. OIDC removes static cloud keys from GitHub, but it does not remove the need for least privilege.
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy
aws-region: us-east-1
Workload identity federation and similar mechanisms exist outside GitHub as well. The details differ by platform, but the principle is stable: grant the CI identity a narrowly scoped role, issue temporary credentials at runtime, and make the trust policy depend on verifiable attributes such as repository, branch, environment, or workflow identity. If a system cannot support federation, keep static secrets narrow in scope, avoid sharing them across unrelated projects, and rotate them on a schedule.
Build rotation, audit, and incident response into the lifecycle
Secrets management is incomplete without lifecycle operations. Every CI secret should have an owner, a purpose, a scope, a storage location, and a rotation path. If rotating a credential requires a risky manual scramble across repositories and environments, the system is already telling you the credential is too broad or too static.
OWASP's CI/CD Security guidance treats secrets as part of identity and access management: use least privilege, avoid shared identities, audit how secrets are used, remove stale access, and monitor for anomalous behavior. Rotation is not only a calendar task. It is the default response after a suspected exposure in logs, artifacts, caches, debug bundles, screenshots, chat transcripts, or third-party compromises.
An exposure response should be boring and repeatable:
- Identify which jobs, workflows, runners, and environments had access to the value.
- Remove the exposed artifact where possible, such as deleting a workflow log or clearing a cache.
- Revoke or rotate the credential at the provider, not just in the CI settings.
- Review provider logs for unexpected use during the exposure window.
- Record what happened and tighten the control that failed, such as permissions, masking, workflow triggers, or cache scope.
Visibility matters here. Audit logs, workflow run history, environment deployment records, and cloud provider access logs make it possible to answer "who could use this secret, and did anything unusual happen?" without guessing. A secret that nobody owns and nobody audits will eventually outlive the reason it was created.
Conclusion
Handling secrets in CI pipelines is less about picking one perfect secret store and more about reducing exposure at every step. Store secrets outside source control, limit which jobs and trusted contexts can read them, assume logs and persisted build data can leak, and prefer short-lived credentials when the target system supports them. The safest secret is the one a pipeline never had to keep, and the second safest is a narrowly scoped credential that can be rotated quickly when assumptions fail.
Sources
- Using secrets in GitHub Actions
- Secure use reference for GitHub Actions
- OpenID Connect for GitHub Actions
- GitLab CI/CD variables
- OWASP CI/CD Security Cheat Sheet