An LLM provider emails "your API key may have been exposed", or the monthly bill jumps for calls nobody made. Searching the repo shows no key in any file. The key is in the Actions log of a public repo: a step's output contains the raw value — readable by anyone on the internet, in a log that lives with the run history.
A CI-only auth failure was being debugged: the workflow's secret-using step
kept getting 401s. To check whether the secret was arriving, a teammate added
run: echo "key is $PROVIDER_API_KEY" — "temporarily". The repo is public,
so its run logs are public too. Linked modules: 4.6 (secrets in CI),
3.2 (API key discipline), 0.2 (WR-006, the pushed secret).
Actions masks repository secrets in logs: the exact value is replaced with
***. So how did the key leak? Masking matches the literal string. Any
transformed print evades it — the value reversed, base64-encoded, split across
two echoes, or embedded inside a longer URL. In this incident the debug step
printed request headers, and the key appeared inside Authorization: Bearer ...
with spacing the mask did not match. The moment any form of the key reached the
log of a public repo, it must be treated as stolen: bots index public CI logs
the same way they index public commits (3.2's lesson). Deleting the step or the
log afterwards does not un-expose anything.
Rotation first, cleanup second — WR-006's order, unchanged. (1) Revoke the key
at the provider and issue a fresh one. (2) Update the repository secret with
the new value — one place, no code change, which is exactly why secrets live
there. (3) Delete the echo/debug step. (4) Check the provider's usage dashboard
for calls during the exposure window. If verification is still needed, print a
property, never the value: print('key length:', len(os.environ['PROVIDER_API_KEY']))
confirms arrival and leaks nothing.
Never print a secret, even "temporarily" — the temporary echo is how nearly
every log leak is born. Verify secrets by length or presence only. Keys enter
steps only through env: lines reading ${{ secrets.NAME }}; a key pasted
into workflow YAML is a key committed to the repo. Review workflow diffs with
the same care as code diffs — a new line that prints environment content should
stop a review. Enable GitHub's secret scanning and push protection on the repo.
Treat masking as a seatbelt, not a licence.
Q: "How do secrets reach a GitHub Actions step, and what do you do if one
shows up in a log?" Strong answer: repository secrets injected per-step via
env:, never hardcoded; masking exists but is evadable by any transformation,
with one concrete example; rotate-first response order; and the print-a-length
verification habit. Bonus signal: noting that public repo logs are public
artifacts, so exposure time — not deletion time — defines the blast radius.