Most secret-handling advice in CI is about hiding a long-lived credential well. That is the wrong end of the problem. A deploy key stored in a repository is valid at three in the morning six months from now, whether or not anybody is still watching the account it belongs to, and every log line, artefact and cache in between is a place it might have escaped to.
The better move is to stop storing it. Then spend what is left of your effort on the credentials that genuinely cannot be replaced that way.
Replace the stored key with a short-lived one
GitHub Actions can present an OpenID Connect token to a cloud provider that trusts it, and the provider hands back an access token. GitHub's own summary of what you get is that you will not need to duplicate your cloud credentials as long-lived GitHub secrets, and that the provider issues a short-lived access token that is only valid for a single job and then automatically expires.
The workflow side is small. The job asks for the token, and the provider's action exchanges it.
# .github/workflows/deploy.yml
name: AWS example workflow
on:
push
env:
BUCKET_NAME : "BUCKET-NAME"
AWS_REGION : "AWS-REGION"
permissions:
id-token: write
contents: read
jobs:
S3PackageUpload:
runs-on: ubuntu-latest
steps:
- name: Git clone the repository
uses: actions/checkout@v6
- name: configure aws credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: ROLE-TO-ASSUME
role-session-name: samplerolesession
aws-region: ${{ env.AWS_REGION }}
- name: Copy index.html to s3
run: |
aws s3 cp ./index.html s3://${{ env.BUCKET_NAME }}/
Two details in that block do real work. id-token: write is what allows the job to request the token at all, and pinning the action to a full commit SHA rather than a tag means the code that handles your credentials cannot change under you.
Scope the trust properly
The token GitHub issues carries a subject claim, and the trust policy on the cloud side is where you decide which workflows that claim will satisfy. The classic format looks like repo:octo-org/octo-repo:ref:refs/heads/demo-branch for a branch, repo:octo-org/octo-repo:ref:refs/tags/demo-tag for a tag, repo:octo-org/octo-repo:pull_request for a pull request, and repo:octo-org/octo-repo:environment:Production for a job tied to an environment.
Match on the exact subject you mean. A policy that accepts any ref in the repository grants production access to anything anyone can push a branch for.
There is a change here that will catch people who copy an old trust policy. Repositories created after July 15, 2026 use an immutable default subject format that includes the owner and repository IDs, shaped like repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH. Older repositories keep the previous format unless they opt in. Check which one your repository issues before you write the policy, and expect the claim to change if you opt in later.
The issuer is https://token.actions.githubusercontent.com, and for AWS the audience to configure is sts.amazonaws.com.
What masking does, and where it stops
For the secrets that remain, GitHub redacts values it knows about from workflow logs. The mechanism has a limit that is easy to walk into, and the documentation states it directly, saying that structured data can cause secret redaction within logs to fail, because redaction largely relies on finding an exact match for the specific secret value.
That single sentence explains most real leaks. Store a JSON service account file as one secret and the log prints its fields individually, none of which is an exact match for the whole blob. Base64 a token to pass it between steps and the encoded form is a different string. Read a password out of a JSON API response mid-job and nothing ever registered it.
Anything derived from a secret has to be registered as one. GitHub is explicit that registering secrets applies to any sort of transformation or encoding as well.
# register a value derived at runtime so it is redacted if it ever appears
SESSION_TOKEN=$(./mint-token.sh)
echo "::add-mask::$SESSION_TOKEN"
echo "SESSION_TOKEN=$SESSION_TOKEN" >> "$GITHUB_ENV"
Never use structured data as the value of a secret. Split the JSON into individual secrets, or have the workflow fetch it from a secret manager and mask the fields it uses.
The leaks that are not log lines
A secret passed as a command-line argument is visible to anything that can read the process table on the same machine, and GitHub names this directly, noting that some jobs use secrets as command-line arguments which another job on the same runner can see with ps x -w. Pass secrets through environment variables or files, and run one job per machine where you can.
Cut the blast radius of the token you did not choose, as well. GITHUB_TOKEN is granted to every workflow, and the sound default is read access to repository contents only, raised per job where a job genuinely needs more.
permissions: {} # everything set to none
jobs:
test:
permissions:
contents: read # this job needs nothing else
Any scope you do not name is set to none once you specify the key at all.
One protection you get for free is that, with the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. The way teams lose that protection is by reaching for pull_request_target, which GitHub warns exposes the repository to security compromises when it is used with the checkout of an untrusted pull request.
When one does reach a log
Assume it is public from the moment it is written. The instruction is short, and it is that if an unredacted secret is sent to a workflow run log, you should delete the log and rotate the secret.
Do the rotation first. Revoking the credential at the provider is what actually stops it being used, and deleting the log afterwards is housekeeping. A stored credential does not expire because the job finished, and it stays valid until somebody revokes it. While you are there, check the artefacts and caches from the same run, since a value that reached the log very likely reached a file too.
Then work out why redaction did not catch it. In almost every case the answer is that the value in the log was not the value that was registered, and the fix is a call to ::add-mask:: at the point the derived value was created. Failing that, it is an argument for moving the credential to OIDC so that the next copy of it is worthless within the hour.
Our security and compliance work covers pipeline credential handling alongside the rest of the estate, and CI/CD pipeline setup is where the OIDC trust policies and least-privilege tokens get built in from the start.
Talk to the engineer who will own your stack.
No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.
Related Articles
How to Detect and Respond to a Compromised Linux Server
A practical incident response guide for Linux servers: identifying signs of compromise, initial triage, evidence preservation, containment, rootkit detection, and writing an incident report.
SecurityEleven npm Packages Compromised in a 53 Minute Attack That Steals Every Credential Your Build Host Can Reach
On August 4, 2026 a worm pushed malicious versions of eleven npm caching packages inside a 53 minute window, harvesting npm tokens, GitHub PATs, AWS credentials, Kubernetes service account tokens and SSH keys. The headline was keyv and its 604 million monthly downloads, but keyv was the safest package on the list: its malicious release was a major version bump that no caret range accepts. The other ten were patch bumps, silently eligible for every dependency range in the ecosystem. That distinction, not the download count, decided who got hit. This is a practical guide to the defenses that actually change the outcome: what your semver range really grants, why npm install and npm ci are not interchangeable, when to disable install scripts and what breaks when you do, and how to check a tree you already have.
SecurityAWS WAF Configuration for Web Application Security
Deploy and configure AWS WAF with managed rule groups, custom rules, rate limiting, and bot control to protect web applications from common threats.