← Actions Secret Leak Check

Why GitHub Actions secret masking fails on a transformed value

GitHub Actions masking works by watching a job's log output stream for the exact, literal string of every declared secret (and any env: var sourced from one), and replacing it with *** before the log is stored. That is a string match, not a data-flow analysis — it has no notion of "derived from a secret", only "is this secret's exact value present right here".

Transform the secret before it reaches the log, and the string the runner is watching for is no longer the string it sees:

- env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
  run: |
    echo "fingerprint: $(echo -n "$API_TOKEN" | base64)"

The base64 encoding of API_TOKEN is a different byte string than API_TOKEN itself. GitHub is watching for the original; it never sees it, so nothing gets masked, and the encoded secret — which still fully reverses back to the original with one command — sits in the log in plain text. Hex encoding, case-folding, a substring (${API_TOKEN:0:8}), URL-encoding, or piping through tr/rev/iconv all have the identical effect: same underlying secret, different string, masking doesn't fire.

The second way: exporting without registering

A related but distinct gap: writing a value to $GITHUB_ENV or $GITHUB_OUTPUT exports it to later steps, but does not, by itself, tell the runner to mask it anywhere.

- env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
  run: echo "token=$API_TOKEN" >> "$GITHUB_OUTPUT"

Here the value written is the original secret string, so GitHub's masking actually does still catch it if a later step echoes $API_TOKEN directly by that same route. The sharper version of this gap is a freshly derived value — an OAuth token your workflow mints using the secret, a signed URL, a short-lived session cookie. That value was never declared as a secret to the runner at all, so it is not on the masking list regardless of how it is printed, until the workflow explicitly calls:

echo "::add-mask::$DERIVED_TOKEN"

The fix

Check your own workflows

Paste your workflow YAML into Actions Secret Leak Check to find every transformed-secret and unmasked-env-export finding across every workflow file at once, or run the CLI in your own repository:

python3 check.py --fail-on-risk

That exits with a nonzero status if any workflow has either pattern — wire it into your own CI to block a newly-added one before it merges.