Skip to main content
GitHub Actions · lesson 2 of 7
GitHub Actions beginner Lesson 2 of 7

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 secrets security act

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.

Frequently Asked Questions

How does GitHub Actions know to mask a secret in the log?
It does simple string matching — before any log line is written, the runner checks whether it contains the exact literal value of a registered secret and replaces that substring with ***. It has no idea what a secret means or looks like; it only recognizes the exact bytes it was told to hide.
Why did masking work for the plain echo but not the base64 version?
The plain echo printed the secret's exact literal value, which matched the masking filter directly. base64 produced a completely different string of characters that happens to decode back to the secret — the masking filter never sees the original value in the output, only its transformed form, so it has nothing to match against and lets it through untouched.
Is this a bug in GitHub Actions, or expected behavior?
It's a well-known, documented limitation, not a bug — GitHub's own docs warn that masking is best-effort and can't catch every possible transformation of a secret. The practical takeaway is that a step which encodes, hashes, or otherwise reshapes a secret's value should never print that result to a public or shared log, since masking gives no protection there at all.
Does putting a secret in an env: block instead of inline in the run: command make it safer?
It's a best practice for readability and reuse, but not for masking specifically — both the inline and the env-variable version in this lesson were masked identically, because the masking filter operates on the final log output regardless of how the value got there. The real protection is never letting the raw or transformed value hit stdout, not which YAML field it's declared in.
Can act's secret handling be trusted to reflect GitHub's actual behavior exactly?
For masking, yes — act implements the same substring-matching masking GitHub's real runners do, which is exactly why the base64 leak reproduces identically here. For other secret-adjacent features (like organization-level secret inheritance or environment-scoped secrets), act's support is more limited and worth verifying against GitHub's real docs before relying on it.