GitHub Actions · lesson 2 of 7
Beginner
2Intermediate
4Also part of GitHub Actions
5- + Bash Error Handling from Bash
- + CI/CD Pipeline Patterns: Sequential, Parallel, and Staged from DevOps
- + Terraform CI/CD with GitHub Actions (plan/apply) from Terraform
- + A CI Security Gate: Gitleaks, Bandit and pip-audit in One Workflow from DevSecOps (AppSec in the pipeline)
- + Migrating a Jenkins Pipeline to GitHub Actions from Jenkins
Also part of: Cryptography DevSecOps (AppSec in the pipeline)
Secrets: Real Log Masking, and the Base64 Trick That Defeats It
GitHub Actions masks a secret printed directly to the log — but transforming it first (base64, a substring, a hash) produces output the masker never recognizes as the secret, leaking it in full.
By SkillByExample · Updated
GitHub Actions masks a secret’s exact value wherever it appears in the log — and “exact value” is doing a lot of work in that sentence.
Masking works for the literal value
# .github/workflows/secrets.yml
name: Secrets
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Print the secret directly
run: echo "token is ${{ secrets.API_TOKEN }}"
- name: Print it into a variable first
env:
TOKEN: ${{ secrets.API_TOKEN }}
run: echo "token via env is $TOKEN"
act push -W .github/workflows/secrets.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest -s API_TOKEN=sk-real-secret-value
[Secrets/build] ⭐ Run Main Print the secret directly
[Secrets/build] | token is ***
[Secrets/build] ✅ Success - Main Print the secret directly [112.871694ms]
[Secrets/build] ⭐ Run Main Print it into a variable first
[Secrets/build] | token via env is ***
[Secrets/build] ✅ Success - Main Print it into a variable first [106.402514ms]
[Secrets/build] 🏁 Job succeeded
-s API_TOKEN=sk-real-secret-value registers the secret the same way GitHub’s real UI would.
Both steps print *** — masking works identically whether the secret is interpolated
directly into run: or passed through an env: variable first, because it operates on the
final log text, not on which YAML field the value came from.
The bug: transforming the secret defeats masking entirely
# .github/workflows/secrets2.yml
name: SecretsBase64
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Base64 encode the secret
env:
TOKEN: ${{ secrets.API_TOKEN }}
run: echo "$TOKEN" | base64
act push -W .github/workflows/secrets2.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest -s API_TOKEN=sk-real-secret-value
[SecretsBase64/build] ⭐ Run Main Base64 encode the secret
[SecretsBase64/build] | c2stcmVhbC1zZWNyZXQtdmFsdWUK
[SecretsBase64/build] ✅ Success - Main Base64 encode the secret [107.874959ms]
[SecretsBase64/build] 🏁 Job succeeded
c2stcmVhbC1zZWNyZXQtdmFsdWUK looks harmless — until you decode it:
echo "c2stcmVhbC1zZWNyZXQtdmFsdWUK" | base64 -d
sk-real-secret-value
The full secret, in the clear, sitting in a public build log. Masking does simple substring matching on the literal registered value — it has no idea what base64 is, so a transformed value sails straight through. This is a documented, well-known limitation, not a bug: anything that reshapes a secret’s bytes before printing removes GitHub’s only protection.
Practice
1. Confirm masking also fails for a substring of the secret.
- name: Print part of the secret
env:
TOKEN: ${{ secrets.API_TOKEN }}
run: echo "${TOKEN:0:5}"
[Substring/build] | sk-re
The full literal value never appears, so nothing gets masked — but sk-re on its own may
already be enough to identify the credential type or narrow a brute-force search, depending on
what the secret actually is.
2. Confirm two different secrets are masked independently.
act push -W .github/workflows/secrets.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest \
-s API_TOKEN=sk-real-secret-value -s SECOND_TOKEN=another-secret-here
[Secrets/build] | token is ***
Only API_TOKEN is referenced in this workflow, so only it gets masked here — registering
SECOND_TOKEN with -s doesn’t affect a workflow that never reads it. Masking is per-secret,
triggered by that secret’s value actually appearing in output, not a blanket “hide everything
registered” rule.
3. Verify that act masks a secret even when it's never explicitly passed with -s but is referenced anyway.
act push -W .github/workflows/secrets.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[Secrets/build] | token is
With no -s API_TOKEN=... supplied, ${{ secrets.API_TOKEN }} resolves to an empty string —
there’s nothing to mask because there’s no secret value at all. This is also a reminder that a
missing secret doesn’t fail a workflow by default; it silently becomes blank.
Next: matrix builds — running the same job across multiple real configurations.