Secrets in Pipelines
Use secrets securely in pipelines — CI/CD foundations.
Focus: use secrets securely in pipelines
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_KEYcan 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:
- Store secrets in a dedicated, encrypted system (like GitHub Actions secrets, GitLab CI/CD variables, or HashiCorp Vault).
- Inject them into the pipeline as environment variables or via secret references — never literal values.
- 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:
- 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.
- 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. - 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. - 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.
- 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
.envfiles 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
- On GitHub, go to your repository.
- Click Settings > Secrets and variables > Actions.
- Click New repository secret.
- Name it
AWS_ACCESS_KEY_IDand paste your actual AWS access key value. - 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), useecho "$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
envblock. 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-repoto purge it from history. Finally, audit who could have accessed the token before revocation.
Pro tip: Always set the
maskoption 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
.envfiles.
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 $SECRETor including them in verbose error messages; masking is a safety net, not a license to leak. - Committing
.envfiles 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
- Use a secret manager like HashiCorp Vault to generate dynamic, short-lived credentials on the fly instead of static secrets.
- Leverage cloud-native secret managers (AWS Secrets Manager, GCP Secret Manager) that support automatic rotation.
- 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_URLas 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.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.