What Nobody Tells You About GitHub Secret Redaction: Why Your Logs Are Still a Security Risk
You’ve been told that GitHub Actions "automatically masks" your secrets in the workflow logs. It’s a comforting thought, right? You define a variable in your repository settings, and GitHub replaces it with *** whenever it pops up in a build. Except, that's not entirely true. GitHub’s redaction engine is a blunt instrument—a simple string-matcher that is surprisingly easy to trick.
If your secret undergoes even the slightest transformation before it hits the console, the mask fails. And once that data is in your logs, it’s permanent until you delete the entire run history.
The String-Match Trap
GitHub doesn't "know" what your secret is in a semantic sense. It just keeps a list of the literal strings you’ve marked as secrets and runs a basic "search and replace" on the log output.
If your secret is SuperSecret123, GitHub looks for exactly those 14 characters. If your code does something as simple as URL-encoding that secret, the redaction engine becomes blind.
# In your workflow
- name: Leaky Request
run: |
# If API_KEY is "My Secret!", the URL becomes ...?key=My%20Secret%21
# GitHub is looking for "My Secret!", not "My%20Secret%21"
curl "https://api.example.com?key=${{ secrets.API_KEY }}"The log will proudly display https://api.example.com?key=My%20Secret%21. You’ve just leaked your key because of three characters of encoding.
The Base64 Blind Spot
In modern CI/CD, we rarely use secrets in their raw form. We often package them into JSON blobs or encode them for headers. This is the most common way secrets leak into logs.
I’ve seen dozens of "secure" pipelines do something like this:
- name: Send Auth Header
run: |
AUTH_BLOB=$(echo -n "user:${{ secrets.PASSWORD }}" | base64)
echo "Debugging auth header: $AUTH_BLOB" GitHub sees the base64 result as a completely different string. It won't mask it. Anyone with read access to the repo can now grab that string, run base64 --decode, and they have your password.
The fix? You have to manually tell GitHub to mask the transformed version using a specific workflow command:
- name: Safe Auth Header
run: |
AUTH_BLOB=$(echo -n "user:${{ secrets.PASSWORD }}" | base64)
echo "::add-mask::$AUTH_BLOB"
echo "Debugging auth header: $AUTH_BLOB" # Now this will be ***Multi-line Secrets and the Indentation Nightmare
Private keys (like SSH keys or GCP service accounts) are multi-line secrets. GitHub is actually okay at masking these *if* they are printed exactly as they are stored.
But what happens when you pipe a JSON secret into a tool like jq or a configuration generator?
If your secret is a JSON object stored in GitHub:{"key": "value", "id": "123"}
And your script does this:
echo '${{ secrets.MY_JSON_SECRET }}' | jq '.'The output will be:
{
"key": "value",
"id": "123"
}Because jq added newlines and spaces for readability, the "literal string" GitHub was looking for is gone. The redaction engine sees five different lines, none of which perfectly match the original one-line JSON blob. Your entire secret is now visible.
The "Short Secret" Problem
GitHub refuses to mask secrets that are too short (usually under 3-4 characters) because it would result in the logs being a sea of asterisks. This makes sense for a secret like 123, but it's a disaster if your "real" secret accidentally contains a very common short string that you happen to use elsewhere.
Even worse, if your secret is composed of multiple parts (like a client ID and a client secret), and you only mask the secret, you might be providing enough context for an attacker to brute-force or identify the account even if the main password is masked.
How to actually stay safe
Don't trust the ***. It's a safety net, not a vault.
1. Use `::add-mask::` aggressively. If you transform a secret (base64, URL encode, etc.), mask the output immediately.
2. Avoid `set -x`. Many shell scripts use set -x for debugging. This prints every command before executing it. If you have an environment variable containing a secret, set -x will often dump it into the logs before GitHub has a chance to catch it.
3. Audit your log output. Run your pipeline with a "dummy" secret that looks like your real one (same length/format) and manually check the logs.
4. Use OIDC whenever possible. The best way to keep secrets out of logs is to not have secrets at all. Use OpenID Connect to authenticate with AWS, Azure, or GCP. You get short-lived tokens that expire automatically, and you never have to store a long-lived password in GitHub's settings.
GitHub Actions is powerful, but it's also a public stage. Write your YAML files with the assumption that everything you echo will be seen by the world. It’s the only way to be sure it won't be.