Secrets in Pipelines

Use secrets securely in pipelines — CI/CD foundations.

Focus: use secrets securely in pipelines

Sponsored

Every developer has felt that moment of dread: a push to GitHub triggers a pipeline, and somewhere in the logs, a password or API token appears in plain text. It's not just an embarrassing typo — it's a security hole that can compromise your entire infrastructure. In CI/CD, secrets like cloud credentials, database passwords, and signing keys are the keys to your kingdom, and handling them carelessly turns your pipeline into an open door. In this lesson, you'll learn how to use secrets securely in pipelines — from understanding the core risks to implementing best practices that keep your credentials safe at every stage.

The problem this lesson solves

When you hardcode a password in a script or push a .env file to your repository, you're not just being sloppy — you're creating an attack vector. Here's what can go wrong:

  • Leaked secrets in logs: Pipelines often print environment variables or command output. A single echo $API_KEY can expose a credential to anyone with access to the logs — and logs are often shared or stored for years.
  • Accidental commits: A developer adds a config file with a hardcoded token, commits it, and pushes. Even if you delete it later, the secret is in Git history forever.
  • Third-party exposure: CI systems and third-party services often access your repository. If a secret is in the code, it's exposed to everyone who clones the repo — even read-only users.
  • Compromised dependencies: A malicious package in your supply chain can read environment variables or files. If your pipeline holds secrets in plain text, they're up for grabs.

The result? Account takeovers, data breaches, and massive cleanup headaches. This lesson equips you with the knowledge to use secrets securely in pipelines — from the mental model that keeps you safe to hands-on steps you can apply today.

Core concept / mental model

Think of a pipeline as a vault with windows. The vault stores your secrets, but every time a step runs, it opens a window to pass values in. If you leave the windows open (by printing secrets, writing them to files, or storing them in the repo), anyone looking in can grab what they need.

The key principle is secrets are a runtime concern, not a build-time artifact. You don't bake secrets into images, scripts, or source code. Instead, you:

  1. Store secrets in a dedicated, encrypted system (like GitHub Actions secrets, GitLab CI/CD variables, or HashiCorp Vault).
  2. Inject them into the pipeline as environment variables or via secret references — never literal values.
  3. Consume them in your scripts without ever echoing or persisting them.

Here's a simple diagram in words:

[Source Code] --> (No secrets!) --> [Pipeline Engine] --> (Secrets injected at runtime) --> [Build/Test/Deploy]

Once you adopt this mental model, you'll start to see secrets as temporary, scoped, and ephemeral — not as configuration that lives in your repo.

How it works step by step

Let's walk through the lifecycle of a secret in a modern CI/CD pipeline, using GitHub Actions as our example:

  1. Define the secret in the CI/CD system's UI or API. GitHub, GitLab, and Jenkins all provide encrypted storage for secrets at the organization, repository, or environment level.
  2. Reference the secret in your pipeline definition using a special syntax. For GitHub Actions, you use ${{ secrets.MY_SECRET }}. This placeholder gets replaced at runtime — it never appears in the workflow file as plain text.
  3. Pass the secret to your job as an environment variable: env: API_KEY: ${{ secrets.API_KEY }}. This makes the value available in the shell but doesn't print it.
  4. Use the secret in your script. The script reads from the environment variable. Avoid echoing it, writing it to a file, or passing it to a command that will print it.
  5. Rotate and revoke secrets regularly. If a secret is ever exposed, revoke it and generate a new one — this limits the damage.

Key rules to internalize:

  • Never hardcode secrets in your source code.
  • Never commit .env files or config files with real secrets.
  • Never print secrets in logs or error messages.
  • Do use environment variables to pass secrets.
  • Do scope secrets to the minimum permissions needed — e.g., an environment-specific secret for production deployment.
  • Do audit access — who can view or update a secret in the CI system?

Hands-on walkthrough

Let's put this into practice with a minimal but complete example. We'll create a GitHub Actions workflow that uses a secret to authenticate to a cloud service — without ever exposing it.

Step 1: Create a GitHub Actions secret

  1. On GitHub, go to your repository.
  2. Click Settings > Secrets and variables > Actions.
  3. Click New repository secret.
  4. Name it AWS_ACCESS_KEY_ID and paste your actual AWS access key value.
  5. Repeat for AWS_SECRET_ACCESS_KEY.

Now your secret is encrypted and stored on GitHub's servers. No one can see it, and it's only accessible to your workflow at runtime.

Step 2: Write a workflow that consumes the secret

Here's a complete workflow file (.github/workflows/deploy.yml):

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Deploy to AWS
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          python deploy.py

Now, in deploy.py, you use the environment variables to authenticate:

import os
import boto3

# Never print these!
session = boto3.Session(
    aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
    aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
)

# Use the session to deploy, e.g., upload to S3
s3 = session.client("s3")
s3.put_object(Bucket="my-app-bucket", Key="index.html", Body=open("index.html").read())
print("Deployment successful")

Expected output (in CI logs):

Deployment successful

You'll notice the secret values never appear in the logs. That's the goal.

Step 3: Test that your secret isn't leaking

Add a step that tries to print the secret to see what happens — but be careful, you don't want to actually leak it. In GitHub Actions, secrets are masked: if your workflow echoes them, the output shows *** instead. Here's a safe test:

- name: Test masking
  run: |
    echo "My secret is $AWS_ACCESS_KEY_ID"
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}

In the logs, you'll see:

My secret is ***

This masking is a safety net, but you should not rely on it — always avoid printing secrets in the first place.

Compare options / when to choose what

There are several ways to handle secrets in CI/CD. Here's a comparison to help you decide:

Approach Pros Cons Best for
Built-in secrets (GitHub/GitLab) Easy setup, encrypted at rest, no extra tooling Limited to one platform, secrets are visible to org admins Startups, simple projects, small teams
Environment variables in CI config Simple, familiar Can be accidentally logged, not encrypted if stored in code Local development only — not for production
Hashicorp Vault Dynamic secrets, fine-grained access control, audit logs Complex to manage, requires infrastructure Enterprises, compliance-heavy environments
Cloud secret managers (AWS Secrets Manager, GCP Secret Manager) Serverless, rotation supported, integrates with cloud Costs money per secret, vendor lock-in Cloud-native apps, microservices
External secrets operator (in Kubernetes) Syncs secrets to K8s automatically K8s-specific, adds latency K8s deployments

General rule: If you're just getting started, use your CI provider's built-in secret management. It's secure enough for most cases and requires zero extra infrastructure. As you scale, consider a dedicated secret manager like Vault for dynamic secrets and compliance.

Troubleshooting & edge cases

Even with best practices, you'll hit snags. Here are common mistakes and how to fix them:

  • Secret shows *** in logs, but you expected a real value: That's actually the masking working. If you need to see the value (e.g., to debug), use echo "$SECRET_NAME" and view the raw log (if allowed) — but avoid doing this in production.
  • Secret is empty in your script: Make sure you've set the secret at the right scope (repository vs environment) and that the environment variable name matches exactly. Check for typos in env: keys.
  • Secret works locally but fails in CI: Locally, you might have the secret in your shell; in CI, it's only available if you've passed it via the env block. Common fix: add the missing environment variable mapping.
  • You accidentally committed a real secret: Don't panic. Immediately revoke the secret, push a fix to remove it, and use a tool like git filter-repo to purge it from history. Finally, audit who could have accessed the token before revocation.

Pro tip: Always set the mask option if your CI system supports it, and use read-only tokens whenever possible. For example, GitHub tokens for Actions have permissions you can restrict to just what's needed.

What you learned & what's next

You now understand why using secrets securely in pipelines is non-negotiable: it protects you from leaks, compromises, and data breaches. You can:

  • Store secrets in your CI system's encrypted vault.
  • Reference them in pipeline definitions without exposing them.
  • Pass them to your scripts as environment variables.
  • Avoid common pitfalls like logging secrets or committing .env files.

Next lesson: In the CI/CD foundations track, you'll move on to artifacts and caching, where you'll learn how to store build outputs efficiently — and keep them secure too. You'll apply the same principles: never hardcode secrets in artifacts, and always isolate sensitive data.

For now, reinforce your learning: go to your CI platform and move one hardcoded credential into a secret, then update your pipeline to use it. This five-minute exercise will cement the pattern forever.

Practice recap

Head to your CI platform (GitHub Actions, GitLab CI, etc.), create a secret for a credential you currently hardcode in a script, and update your pipeline to inject it as an environment variable. Then, run the pipeline and verify the secret never appears in logs. Try echoing it once to see masking in action, then remove that line for good.

Common mistakes

  • Hardcoding secrets in source code — even for a 'small' project — exposes credentials to anyone with repo access and to Git history forever.
  • Printing secrets to logs with echo $SECRET or including them in verbose error messages; masking is a safety net, not a license to leak.
  • Committing .env files or config files with real credentials; if you must commit a template, use placeholder values and document the process.
  • Using a single, long-lived secret for multiple environments; a compromise in staging can escalate to production.
  • Assuming repository-level secrets are enough — forgetting to scope sensitive actions like deploys to environment-specific secrets.

Variations

  1. Use a secret manager like HashiCorp Vault to generate dynamic, short-lived credentials on the fly instead of static secrets.
  2. Leverage cloud-native secret managers (AWS Secrets Manager, GCP Secret Manager) that support automatic rotation.
  3. For Kubernetes, integrate an external-secrets operator to sync secrets directly into clusters, avoiding manual base64 encoding in manifests.

Real-world use cases

  • Deploying a web app to AWS — store AWS access keys as GitHub Actions secrets, inject them as env vars, and use them in your deploy script.
  • Running database migrations on GitLab CI — pass DB_URL as a masked CI/CD variable, never in the repo.
  • Signing mobile app releases — keep your signing keystore and password in a secret store, inject them at build time only.

Key takeaways

  • Secrets must live outside your source code and be injected at runtime — never commit or hardcode them.
  • Use your CI/CD provider's built-in secret management for simplicity and for secure encryption at rest.
  • Pass secrets to jobs as environment variables and avoid printing them in logs or writing them to files.
  • Scope secrets to the minimum permissions and rotate them regularly to limit blast radius.
  • If a secret leaks, revoke it immediately and purge it from Git history with tools like git filter-repo.
  • For advanced needs, integrate a dedicated secret manager (Vault, cloud managers) for dynamic and audited secret delivery.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.