Working Safely With Secrets
Storing a value as a secret only solves half the problem. The other half is making sure it stays a secret once your workflow actually starts using it — passed to a script, embedded in a command, logged by a tool that doesn’t know it’s handling something sensitive. GitHub does some of this masking automatically, but understanding exactly what it catches and, more importantly, what it doesn’t, is what keeps a secret from ending up sitting in plaintext in a public log.
Automatic Masking — What It Actually Does
GitHub scans log output for any exact string that matches a secret’s value and replaces it with *** before it’s ever displayed or stored.
name: Masking Demo
on: workflow_dispatch
jobs:
demo:
runs-on: ubuntu-latest
steps:
- run: echo "The token is ${{ secrets.DEMO_TOKEN }}"If DEMO_TOKEN is set to mysecret123, the log line shows The token is *** — not the literal value. Set this up to confirm it yourself:
mkdir gha-secret-safety-demo && cd gha-secret-safety-demo
git init
mkdir -p .github/workflows
cat > .github/workflows/masking.yml << 'EOF'
name: Masking Demo
on: workflow_dispatch
jobs:
demo:
runs-on: ubuntu-latest
steps:
- run: echo "The token is ${{ secrets.DEMO_TOKEN }}"
EOF
git add .
git commit -m "Add masking demo workflow"
git branch -M main
git push -u origin mainAdd a repository secret DEMO_TOKEN with value mysecret123 through GitHub’s Settings UI, then trigger the workflow manually — the log will show *** in place of the value.
WARNING On Automatic Masking
GitHub Actions automatically masks known secret values in workflow logs, but you should not treat masking as a guarantee that a secret cannot leak.
Masking is primarily based on recognizing the secret value in the output. If a script or tool transforms the secret before printing it—for example, by Base64-encoding it, URL-encoding it, changing its case, or inserting characters between its characters—the resulting output may no longer match the value GitHub expects to redact.
For example:
- name: Demonstrate transformed output
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: |
# Transforming the secret can prevent automatic masking.
echo "Encoded print:"
echo -n "$MY_SECRET" | base64
echo "Separated characters:"
echo -n "$MY_SECRET" | sed 's/./& /g'The first command prints an encoded representation of the secret, while the second inserts spaces between characters. Neither output is necessarily recognized as the original secret, even though the underlying value can still be recovered.
This is why you should never rely on GitHub’s log masking as your only protection. Be especially careful when passing secrets to third-party or unfamiliar actions, scripts, and tools. A trusted action can still accidentally expose a secret through transformed output, artifacts, error messages, or external requests.
The safest approach is to minimize where secrets are passed, avoid printing them or derived values, and only use third-party actions that you trust.
Passing Secrets Into Commands Safely
The safest pattern is passing a secret as an environment variable to a step, rather than interpolating it directly into a shell command string.
- name: Safer pattern
env:
API_TOKEN: ${{ secrets.DEMO_TOKEN }}
run: |
curl -H "Authorization: Bearer $API_TOKEN" https://example.com/apiCompare this to directly embedding the expression in the command:
- name: Riskier pattern
run: curl -H "Authorization: Bearer ${{ secrets.DEMO_TOKEN }}" https://example.com/apiBoth work, and both benefit from masking — but the env: pattern is generally preferred because it avoids the secret’s value ever being substituted directly into a shell command line, which matters more once shell escaping and quoting get involved. A malformed value with a stray quote or $ character behaves very differently depending on whether it’s interpolated straight into the command string versus passed cleanly through an environment variable.
Never Log A Secret On Purpose (Even For Debugging)
A common mistake: temporarily adding echo "DEBUG: token is ${{ secrets.DEMO_TOKEN }}" while troubleshooting a workflow, planning to remove it before merging, and forgetting to. Even though GitHub masks the printed value in that specific run, the masking is a display-layer feature — the underlying log storage and any log-forwarding integration you have (shipping logs to an external service, for instance) may not apply the same masking, especially for values that get transformed before printing.
Tip
If you need to debug whether a secret is set at all, check for presence and length, never the value: echo "Token length: ${#API_TOKEN}" tells you something is there without ever printing the actual content.
Secrets Don’t Pass To Forked Pull Requests
As covered when discussing the pull_request event, workflows triggered by a pull request from a fork run without access to your repository’s secrets by default — this is a deliberate GitHub security boundary, not an oversight. A malicious actor could otherwise open a PR from their fork containing a workflow step that just echoes every secret your repository has, and have GitHub run it with full access.
If you need secrets available to fork-based PR workflows (for a public open-source project that still needs to run integration tests requiring credentials), that requires deliberately opting in with pull_request_target and taking on the associated risk carefully, which is beyond what’s safe to casually reach for.
Secrets In Reusable and Composite Actions
When a workflow calls another action (via uses:), secrets aren’t automatically passed through — a third-party action doesn’t get implicit access to your repository’s secrets just by being called. You have to pass them explicitly:
- uses: some-org/some-action@v1
with:
token: ${{ secrets.DEMO_TOKEN }}This is a deliberate safety boundary too: you decide exactly which secrets a third-party action receives, rather than it having blanket access to everything your workflow can see.
How Real DevOps Engineers Use This
Mature teams treat “does this leak a secret” as a real code-review question for workflow changes, not an afterthought — the same scrutiny given to a pull request that touches production infrastructure. A common practice is avoiding tools inside CI that aren’t secret-aware: a script that logs its full HTTP request for debugging purposes, including headers, is a classic way a bearer token ends up in a log even though the workflow author never directly echoed it.
Rotating credentials on a schedule, and treating any credential that touches CI as more exposed than one that only lives on a personal laptop, is standard practice at organizations that take this seriously — a leaked CI secret is often more damaging than a leaked personal credential, since CI pipelines commonly have broad permissions (deploy access, infrastructure changes) by design.
Clean up:
cd .. && rm -rf gha-secret-safety-demoDelete the DEMO_TOKEN secret from the demo repository’s Settings once you’re done, and consider rotating any real secret you ever suspect might have leaked, rather than assuming it’s fine.