Masking cannot hide a value it does not recognize. It redacts exact matches of registered secrets on the runner, so a transformed copy, a secret buried in a JSON blob, or a value never registered can appear in logs in plain text. Treat masking as a safety net, not a control.
How masking works
GitHub's guidance says redaction happens on the runner, so a secret is only redacted if it was used within a job and is accessible to the runner. Values stored as secrets are masked automatically. Other sensitive values can be masked with the add-mask workflow command, which treats the value as a secret and redacts it on the runner.
Two details from the workflow command reference are easy to miss. Masking must be done once per value per job, and you must register a value before you output it. And once a value is masked, you cannot set it as a step output.
What slips through
| Case | Why it is not hidden |
|---|---|
| A secret stored as a JSON, XML or YAML blob | Redaction relies on exact matches, so printing one field of the blob does not match the whole secret |
| An encoded or transformed copy, such as base64 of the secret | It is a different string, unless you register it too |
| A value you generated or fetched at run time | Not a stored secret, so not masked unless you add the mask |
| Output from a tool that reformats the value | Case changes, splitting or escaping break the exact match |
| A secret passed in command line arguments | Other jobs on a shared runner may see it in the process list |
GitHub's documentation addresses each of these. It recommends creating a separate secret for each sensitive value rather than one structured secret, registering any value derived from a secret as a secret too, and warns that secrets in command line arguments can be visible to other jobs on the same runner.
A checklist for safer logs
- One secret per value. Store a username, a password, and a key as three secrets, not as one JSON object.
- Register derived values. If a script base64 encodes a token, mask the result before it is used or printed.
- Mask run-time credentials. When a step fetches a short-lived token, call add-mask on it right away, before any command prints it.
- Use environment variables for secrets, not arguments. This keeps them out of process listings.
- Turn off debug output for jobs that handle secrets. Verbose modes can print more than you expect.
- Never print secrets to confirm they are set. Check for emptiness instead.
- Review third-party actions for logging of inputs. A compromised or careless action can print what it receives, in a form masking does not match.
When a secret does leak
GitHub's advice is direct: if a secret reaches a log, delete the log and rotate the secret. Deleting the log is not enough on its own, because anyone who read it, or any system that copied it, has the value. Rotation ends the exposure. Plan for that before you need it, so the rotation steps are written down and tested.
Read access matters too. Anyone with write access to a repository can read its secrets, so the people who can edit workflows are part of your trust boundary. See protecting secrets in CI test logs and artifacts.
Prefer not to store secrets at all
Where you can, use short-lived credentials from OIDC rather than a stored cloud key. A token that expires with the job is a smaller loss if it appears in a log. See GitHub Actions OIDC to replace long-lived cloud secrets.
Quick check
Will masking hide a base64 version of my secret?
Not unless you register that encoded value too. GitHub's guidance says to treat derived values, including encoded forms, as secrets.
Is deleting a log enough after a leak?
No. Delete it, and rotate the secret. Assume the value was seen.
Sources
Last reviewed: 19 October 2026.