GitHub Actions Secrets How to Inject Config Without Hardcoding Anything Embarrassing
Secrets, vars, environment protection rules, log masking, Docker build args — here's how CI/CD secrets actually move through GitHub Actions without leaking.
Every team has at least one workflow where someone hardcoded a token “just for now.” Two years later it’s still there, the person who did it has moved on, and the key has never been rotated.
GitHub Actions has a real secrets system. The problem isn’t the tooling — it’s that most teams learn it by trial and error, usually after something embarrassing shows up in a log. Here’s the full picture: from the basics to the traps that catch experienced teams.
secrets vs vars: What Goes Where
GitHub Actions gives you two separate contexts for injecting config:
- secrets.* — encrypted at rest, masked in logs, never exposed to workflows triggered by forked PRs. Use for: API keys, tokens, passwords, private keys, anything you’d rotate if exposed.
- vars.* (added in 2023) — plaintext org/repo config, not masked. Use for: base URLs, feature flag names, timeout values, environment identifiers.
The mistake most teams make: putting non-sensitive config in secrets “just to be safe.” This makes debugging painful — you can’t see the value in logs even when you need to confirm a URL is correct. If you’d be fine printing it to the console, it belongs in vars.
How Masking Works (and Where It Fails)
When GitHub Actions encounters a value from secrets.*, it replaces every occurrence in logs with ***. This covers the common case, but has real gaps:
- Multi-line secrets: Each line is masked individually, not the full value. A private key stored as a secret will show multiple
***blocks in logs — masked, but the structure is obvious from context. - Derived values: Base64-encoding a secret does not mask the result. Running
echo ${{ secrets.TOKEN }} | base64logs the encoded value in plaintext. Masking only covers the exact raw string. - Short secrets: GitHub won’t mask secrets shorter than 3 characters. If your secret is literally
no, it won’t be masked anywhere.
The Right Injection Pattern (and What Not to Do)
Here’s an annotated workflow showing correct patterns alongside the mistakes that look fine but cause problems:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # environment protection rules apply here
env:
# Non-sensitive config uses vars, not secrets
API_BASE_URL: ${{ vars.API_BASE_URL }}
steps:
- uses: actions/checkout@v4
# Secret injected as step-level env var — tightest possible scope
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./scripts/deploy.sh
# BAD: secret interpolated directly in run command
# Even masked in logs, the value lives in the process list
# and can surface in error output and debug traces
- name: Do not do this
run: curl -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://api.example.com
# GOOD: same request, secret via env var
- name: Do this instead
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com
The rule to remember: never reference ${{ secrets.X }} inside a run: block. Set it as an env: var at the job or step level, then reference the shell variable $X in your script. Interpolation at the workflow level is masked; in-shell interpolation is not reliably safe.
Docker Build Args: The Trap That Burns People
This is where experienced teams get caught:
# Never do this — secret gets baked into image layer history
- name: Build
run: docker build --build-arg API_KEY=${{ secrets.API_KEY }} -t myapp .
这 --build-arg value is stored in the image layer history. Anyone with pull access to the image can run docker history myapp and see it. Even after rotating the key, it lives in the registry history until the image is explicitly purged.
Fix A — pass at runtime, not build time. If the secret is only needed when the container runs, keep it out of docker build entirely:
- name: Run container
env:
API_KEY: ${{ secrets.API_KEY }}
run: docker run -e API_KEY="$API_KEY" myapp
Fix B — Docker BuildKit secrets. If you need the secret during the build itself (e.g., pulling from a private npm registry), use BuildKit’s secret mount. It’s available only during the specific RUN step that requests it and is never stored in image layers:
- name: Build with BuildKit secret
env:
DOCKER_BUILDKIT: 1
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "$NPM_TOKEN" | docker buildx build --secret id=npm_token,env=NPM_TOKEN -t myapp .
And in your Dockerfile:
RUN --mount=type=secret,id=npm_token NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
Environment Protection Rules
For deployment workflows, use a GitHub environment (Settings → Environments) instead of repo-level secrets. Environments let you:
- Scope secrets to specific environments — production secrets aren’t available to staging jobs
- Require reviewers — a human must approve before the job runs, regardless of what triggered it
- Add wait timers — delay deployments by up to 30 days after other checks pass
- Restrict to specific branches — only
maincan deploy to production, even if someone triggers the workflow manually from another branch
One gotcha: the environment name in your workflow YAML must exactly match what’s configured in GitHub. A mismatch doesn’t error — the job just runs without any protection rules applied. Silent and dangerous.
Mistakes That Leak Tokens in the Wild
- Echo debugging:
run: echo "Token: ${{ secrets.TOKEN }}"— GitHub masks the value, but the line appears in logs and may expose partial context or adjacent values in error messages. - pull_request_target misuse: This trigger runs in the base repo context and has access to secrets. If you check out
${{ github.event.pull_request.head.sha }}inside that workflow, you’re running attacker-controlled code with your production credentials in scope. GitHub’s docs on this have improved, but it still catches teams with complex workflows. - set -x in deploy scripts: If your shell script runs with
set -x, it prints every command before executing — including the full value of every env var that contains a secret. - Broad job-level env vars with third-party Actions: Every action in a step has access to all env vars defined for that job. If you define secrets at the job level, you’re exposing them to every step, including third-party ones. Use step-level env vars for secrets wherever you can.
Preparing Multi-Line Secrets
Secrets like SSH keys, service account JSON, or TLS certificates contain newlines, which makes them awkward to paste into a GitHub Secrets field. The standard approach is to base64-encode them first:
# Encode locally before adding to GitHub Secrets
base64 -w 0 service-account.json
Then decode in the workflow before use:
- name: Set up credentials
env:
SERVICE_ACCOUNT_B64: ${{ secrets.SERVICE_ACCOUNT_B64 }}
run: echo "$SERVICE_ACCOUNT_B64" | base64 -d > /tmp/service-account.json
If you don’t have a terminal handy, the Base64 Encoder on IO Tools handles it in the browser — paste your key, copy the encoded string, add it to GitHub. Also worth mentioning: the 空白修剪器 is useful when copying tokens from dashboards, since invisible trailing spaces silently break authentication and can be surprisingly hard to track down.
The Mental Model
GitHub Actions secrets are a transport mechanism, not a vault. They move config from GitHub’s encrypted store into your workflow’s environment variables. Once they land in an env var, your scripts, processes, and third-party actions interact with them — and that’s where leaks actually happen.
Keep secret references out of run: blocks. Never use Docker build args for sensitive values. Use environments with protection rules for anything that touches production. When a deploy fails and you need to debug, reach for vars and debug logging before touching the secrets.
