2026-07-26
Mask transformed GitHub Actions credentials before they can leak
Handle generated credentials inside one job, pass only a handle across jobs, and contain an exposed workflow run.
GitHub Actions can redact a configured secret value in logs, but that protection is not guaranteed once a value has been encoded, reformatted, or otherwise transformed. Treat every generated credential and every form that might reach stdout or stderr as a separate value that needs protection. GitHub also advises avoiding structured secret blobs when separate values will do. Secure use reference
This article was source-reviewed on 2026-07-26. No GitHub-hosted workflow was executed for this publication.
Mask the value before the next command can fail
The order matters. Preserve trailing newlines during acquisition before rejecting a credential that is not a nonempty single-line value, then emit ::add-mask:: immediately before a diagnostic, validation command, or tool invocation can write it. These examples reject CR or LF and percent-escape raw command data; their simple name=value output is a separate line-oriented contract. GitHub masks a registered value for the rest of that job. It does not infer that a base64 string, URL-escaped token, JSON field, or exchanged credential is equivalent to an existing secret, so register each form that could be output. Review failure paths as carefully as success paths because tools can disclose values through stdout or stderr. Secure use reference Masking a value in a log
Keep the credential in the job that needs it. This example captures a raw credential without putting it on a command line, masks it, makes it available only to a later step in the same job, and sends it to the client over standard input. credential-broker and deploy-client are placeholders for tools that support these interfaces. The example never prints the credential.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- id: credential
name: Mint and mask the credential
shell: bash
run: |
set -euo pipefail
credential_capture="$(credential-broker issue --format raw && printf .)"
credential="${credential_capture%.}"
unset credential_capture
if [[ -z "$credential" || "$credential" =~ ^[[:space:]]+$ || "$credential" == *$'\r'* || "$credential" == *$'\n'* ]]; then
printf '%s\n' 'credential must be a nonempty single-line value' >&2
exit 1
fi
mask_data="${credential//%/%25}"
printf '::add-mask::%s\n' "$mask_data"
unset mask_data
printf 'credential=%s\n' "$credential" >> "$GITHUB_OUTPUT"
- name: Deploy without a credential argument
shell: bash
env:
DEPLOY_CREDENTIAL: ${{ steps.credential.outputs.credential }}
run: |
set -euo pipefail
printf '%s' "$DEPLOY_CREDENTIAL" | deploy-client --credential-stdinadd-mask must occur before the value appears. GitHub documents that a masked value may be used through GITHUB_OUTPUT by later steps in the same job, but cannot be a workflow output. Avoid passing credentials as command-line arguments where practical, because process listings or audit events may capture them. Masking a value in a log Using secrets in GitHub Actions
Do not place echo "$credential", shell tracing such as set -x, verbose HTTP output, request dumps, or an error handler that serializes its environment between acquisition and use. If the workflow generates a transformed form for a legitimate API boundary, mask that form too before invoking anything that could log it:
set -euo pipefail
credential_capture="$(credential-broker issue --format raw && printf .)"
credential="${credential_capture%.}"
unset credential_capture
if [[ -z "$credential" || "$credential" =~ ^[[:space:]]+$ || "$credential" == *$'\r'* || "$credential" == *$'\n'* ]]; then
printf '%s\n' 'credential must be a nonempty single-line value' >&2
exit 1
fi
mask_data="${credential//%/%25}"
printf '::add-mask::%s\n' "$mask_data"
unset mask_data
encoded_credential="$(printf '%s' "$credential" | base64 | tr -d '\n')"
if [[ -z "$encoded_credential" || "$encoded_credential" =~ ^[[:space:]]+$ || "$encoded_credential" == *$'\r'* || "$encoded_credential" == *$'\n'* ]]; then
printf '%s\n' 'encoded credential must be a nonempty single-line value' >&2
exit 1
fi
mask_data="${encoded_credential//%/%25}"
printf '::add-mask::%s\n' "$mask_data"
unset mask_data
printf '%s' "$encoded_credential" | api-client --token-stdin
unset credential encoded_credentialCross a job boundary with a handle, not the credential
Masked values cannot be cross-job outputs. When another job needs the capability, store the credential in an approved secret store and publish only a nonsecret, scoped handle. The receiving job retrieves it, masks it before any possible output, and keeps it local to that job. GitHub documents this store-and-handle pattern for passing secrets to another job or workflow. Masking a value in a log
jobs:
issue:
runs-on: ubuntu-latest
outputs:
credential_handle: ${{ steps.store.outputs.credential_handle }}
steps:
- id: store
shell: bash
run: |
set -euo pipefail
credential_capture="$(credential-broker issue --format raw && printf .)"
credential="${credential_capture%.}"
unset credential_capture
if [[ -z "$credential" || "$credential" =~ ^[[:space:]]+$ || "$credential" == *$'\r'* || "$credential" == *$'\n'* ]]; then
printf '%s\n' 'credential must be a nonempty single-line value' >&2
exit 1
fi
mask_data="${credential//%/%25}"
printf '::add-mask::%s\n' "$mask_data"
unset mask_data
credential_handle_capture="$(printf '%s' "$credential" | secret-store put --stdin --format raw && printf .)"
credential_handle="${credential_handle_capture%.}"
unset credential_handle_capture
if [[ -z "$credential_handle" || "$credential_handle" =~ ^[[:space:]]+$ || "$credential_handle" == *$'\r'* || "$credential_handle" == *$'\n'* ]]; then
printf '%s\n' 'credential handle must be a nonempty single-line value' >&2
exit 1
fi
printf 'credential_handle=%s\n' "$credential_handle" >> "$GITHUB_OUTPUT"
unset credential
use:
needs: issue
runs-on: ubuntu-latest
steps:
- name: Retrieve and use the credential
shell: bash
env:
CREDENTIAL_HANDLE: ${{ needs.issue.outputs.credential_handle }}
run: |
set -euo pipefail
credential_capture="$(secret-store get "$CREDENTIAL_HANDLE" && printf .)"
credential="${credential_capture%.}"
unset credential_capture
if [[ -z "$credential" || "$credential" =~ ^[[:space:]]+$ || "$credential" == *$'\r'* || "$credential" == *$'\n'* ]]; then
printf '%s\n' 'credential must be a nonempty single-line value' >&2
exit 1
fi
mask_data="${credential//%/%25}"
printf '::add-mask::%s\n' "$mask_data"
unset mask_data
printf '%s' "$credential" | deploy-client --credential-stdin
unset credentialChoose the handle's scope and expiry so it cannot silently become a second long-lived credential. For cloud access, GitHub Actions OIDC can obtain short-lived provider tokens instead of storing a long-lived cloud secret, when the provider and trust policy support it. About security hardening with OpenID Connect
Contain an exposure in the right order
If an unredacted credential reached a log, assume it was exposed. Masking is not retroactive, and deleting a workflow run does not revoke access. Start with the credential, then reduce the residual copies.
-
Revoke or rotate the exposed credential first. An expired or revoked GitHub token cannot authenticate and cannot be restored. Create the replacement with the least scope and shortest useful lifetime, update the secret store or provider configuration, then validate that the replacement works for its intended operation. Token expiration and revocation
-
Delete the exposed workflow run after rotation. A user with write access can permanently delete an eligible completed run, or a run more than two weeks old. Use its run ID and repository explicitly:
gh run delete 1234567890 --repo OWNER/REPOSITORY -
Inspect adjacent sinks: job summaries, artifacts, uploaded diagnostic files, check annotations, external log forwarding, notification integrations, and any downstream system that received the credential. Remove or restrict those copies according to their retention controls.
-
Validate the replacement credential from the intended workflow path without printing it. Confirm the revoked credential is rejected, confirm the replacement has only the required access, and check that the corrected workflow masks both the raw and transformed forms before an error path can run.
Deleting a run limits access to that run's data. Rotation or revocation limits the credential itself. Both actions are required when a real value reached a log.