GitHub Actions secrets done rightWhat works. Where it breaks. What to audit.
GitHub Actions has decent secret handling by default. The failure modes are specific, underdiscussed, and show up in production pipelines at companies that think they've got this covered.
-
01
Source
Repository secret
Secret stored in repo settings, injected as env var
secrets.MY_KEY -
02
Vector
env: step + printenv
env context exposes the value outside secrets.* scope
log line · unredacted -
03
Exposure
Action log, 90 days
anyone with repo read access can pull the log
default retention
TL;DR· the answer, in twenty seconds
What: GitHub Actions redacts secrets.* references in log output, but values exposed through the env: context bypass that redaction. The pull_request_target event runs with full repo secrets while executing code from the PR branch, a combination that has burned several projects.
Fix: Audit every workflow for pull_request_target paired with a checkout, replace long-lived credentials with OIDC federation to AWS, GCP, or Azure, and grep your workflows for printenv and env | grep.
Lesson: Secret handling systems work until the value leaves the system's perimeter. The perimeter in GitHub Actions is narrower than most people assume.
GitGuardian's State of Secrets Sprawl 2026 put AI-service token leaks at +81% year-over-year, with AI-assisted commits leaking at roughly twice the baseline rate. GitHub Actions is the pipeline where most of those commits run.
GitHub's built-in secret handling is good. Repository secrets and environment secrets with approval gates are sound defaults. The secrets.* redaction in logs is real. OIDC federation to cloud providers eliminates long-lived credentials entirely. Most teams using these features correctly are in a reasonable place.
Most teams are not. The ones that have leaked are usually tripped by three specific mechanisms, plus one event trigger that bites someone new every quarter. None of these are obscure. They show up in production repos with CI badges and green checkmarks.
What to know in 60 seconds
secrets.*values get replaced with***in GitHub Actions logs. Values passed throughenv:context do not.pull_request_targetruns with the target branch's secrets while executing the PR branch's code if you add a checkout step. It was designed for comment-on-PR workflows. It is misused constantly.- OIDC federation to AWS, GCP, or Azure removes long-lived credentials from
secrets.*entirely. The token GitHub mints is short-lived and scoped to the job. - Reusable workflows called from a workflow with
secrets: inheritget everything, including secrets you didn't intend to share. - GitHub Actions logs default to 90-day retention. Anyone with repository read access can download them.
What GitHub Actions does well
Repository secrets stored in Settings > Secrets and variables are encrypted at rest and injected into jobs as environment variables at runtime. GitHub masks them in log output by replacing any occurrence of the value with ***. For short, high-entropy values like API keys, this works reliably.
Environment secrets add a second layer. You define environments (staging, production) with approval gates and deployment branch rules, then scope secrets to those environments. A workflow step running in the production environment can only access production secrets if it's running on the right branch and a required reviewer approved it. This is the right model for credentials that touch production infrastructure.
OIDC federation is the best available option for cloud credentials. When your workflow authenticates to AWS using aws-actions/configure-aws-credentials with role-to-assume, GitHub mints a short-lived JWT, AWS validates it against the configured trust policy, and the workflow gets temporary credentials scoped to that role. No AWS_ACCESS_KEY_ID sits in repository secrets. Nothing to rotate. Nothing to leak through a printenv call.
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
The equivalent setup exists for GCP (Workload Identity Federation) and Azure (federated credentials on a managed identity).
Three failure modes that ship in production
env: + printenv bypasses redaction
GitHub masks secrets.MY_KEY when it appears directly in a run step. The mask targets the expression value as GitHub evaluates it. When you assign a secret to an environment variable and then run printenv, you print the raw string, not the expression, and the masker does not follow.
- name: Debug environment
env:
MY_KEY: $
run: printenv # prints MY_KEY=sk-live-abc123 unredacted
This pattern turns up in debug steps that never get removed, in wrapper scripts calling env | grep KEY to verify injection, and in Docker steps passing --env-file <(env) to a container. The log captures the output. It stays for 90 days. Anyone with repo read access can download it.
To verify a secret is set without printing it:
[ -n "$MY_KEY" ] && echo "key is set" || echo "key is missing"
Multi-line secrets and double JSON encoding
GitHub's masker works against the literal stored value. Multi-line secrets, like RSA private keys or JSON credentials, get URL-encoded or JSON-encoded differently depending on how they transit the pipeline. A secret with newlines may appear in a log in a form the masker never sees.
Concrete failure: a team stores a GCP service account JSON blob as a repository secret. The workflow assigns it to an env var. A Python script reads the var, parses it, then logs an error that includes part of the credential. The log line holds a newline-encoded or quote-escaped fragment the masker wasn't trained on.
Base64-encode multi-line secrets before storing them in GitHub, then decode in the workflow step. A single base64 line with no embedded whitespace is what the masker expects.
# Store this in the secret
base64 -w 0 service-account.json
# Decode in the workflow
echo "$GCP_CREDENTIALS_B64" | base64 -d > /tmp/gcp-creds.json
Delete /tmp/gcp-creds.json in a cleanup step that runs even on failure.
Reusable workflows and scope escalation
GitHub lets workflows call other workflows. The caller controls what secrets the callee receives. Two options: pass specific secrets by name, or use secrets: inherit to forward everything.
secrets: inherit is the convenient path and the dangerous one. The callee receives all secrets the caller has access to, including environment secrets from environments the callee was never meant to touch. Call a third-party reusable workflow via uses: org/repo/.github/workflows/deploy.yml@main with secrets: inherit, and if that repo is ever compromised, the attacker inherits your full secret set.
Pass secrets by name instead:
jobs:
call-workflow:
uses: ./.github/workflows/reusable.yml
secrets:
DEPLOY_TOKEN: $
Audit every secrets: inherit in your workflows before you read further.
The pull_request_target trap
pull_request_target fires when a PR opens against the target branch. It runs with the target branch's full secret context. The event was designed for workflows that post comments or update statuses: things that need repo write permissions but do not need to execute the PR's code.
The trap opens the moment you add a checkout step:
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: $ # checks out PR branch code
- run: npm test # runs attacker-controlled code with repo secrets in scope
An attacker opens a PR. The workflow checks out their branch, runs their code, and their code sees every secret in your repository. No write access required. A GitHub account is enough.
GitHub's documentation calls this out. The _target in the event name is the signal. Teams read it as "more powerful pull_request" and add checkout steps without catching the hazard.
Fix: do not check out the PR branch in pull_request_target workflows. If the job needs the PR's code, use pull_request (no target branch secrets) and pass any required tokens explicitly, such as a GitHub App installation token scoped to what that step actually needs.
The part most teams get backwards
The standard advice is "use environment secrets with approval gates for production." Correct advice, as far as it goes. What gets left out: environments only protect secrets if the workflow runs on a protected branch.
Your production environment requires a reviewer. Fine. But if any branch can trigger a deployment to that environment, an attacker with push access to a non-protected branch runs the deployment workflow, waits for approval, and executes with production secrets in scope. Environment protection only works paired with deployment branch restrictions.
Second miss: teams audit the workflows on their default branch and ignore feature branches. Workflow files in non-default branches run in CI. A developer adds printenv to a workflow in a feature branch for a debugging session, forgets to remove it, the PR review misses the workflow diff, and the leak ships. Require CODEOWNERS review for .github/workflows/ changes.
# .github/CODEOWNERS
.github/workflows/ @security-team
A 10-step audit for an existing repo
Run this against any repo that uses GitHub Actions secrets.
## GitHub Actions secrets audit
- [ ] Search all workflow files: grep -r "pull_request_target" .github/workflows/
For each match: does the job check out the PR branch? Remove the checkout or switch to pull_request.
- [ ] Search for printenv and env piped to grep:
grep -rE "printenv|env \|" .github/workflows/
Remove or replace with non-printing checks.
- [ ] Search for secrets: inherit in reusable workflow calls:
grep -r "secrets: inherit" .github/workflows/
Replace with explicit secret names.
- [ ] Audit every repository secret. Does it still exist? Is it still rotated?
Settings > Secrets and variables > Actions.
- [ ] List all environments. Do production environments require reviewers?
Settings > Environments. Verify branch restrictions on each.
- [ ] Verify CODEOWNERS covers .github/workflows/:
cat .github/CODEOWNERS | grep workflows
- [ ] Check log retention: Settings > Actions > General > Artifact and log retention.
Default is 90 days. Lower it if you don't need historical logs.
- [ ] Check for multi-line secrets (JSON blobs, private keys). Are they base64-encoded?
Plaintext multi-line secrets may leak in encoded form that the masker misses.
- [ ] Identify every cloud credential in secrets.*. Can it move to OIDC federation?
AWS, GCP, and Azure all support it. Pick one this quarter.
- [ ] Pull the last 10 workflow runs and download their logs.
grep -i "key\|token\|secret\|password" through them.
Anything that looks like a real value? Rotate and fix the step.
What this means for your stack
GitHub's secret handling removes the worst failure modes when you use it as designed: environment secrets with branch restrictions, OIDC for cloud credentials, explicit secret names in reusable workflow calls. The gaps are at the perimeter, where values leave the secrets.* context and enter env vars, log output, or cached artifacts.
Secrets in CI pipelines travel through more surfaces than most teams track. A secret injected as an env var at job start can appear in a debug step, land in a downloaded log, and sit there for 90 days. No bug in GitHub's handling required. Just ordinary workflow authoring choices that no one flagged in review.
hasp is one working implementation of a tighter runtime model. curl -fsSL https://gethasp.com/install.sh | sh, hasp setup, connect a project, and the next process gets a scoped, time-limited credential injected at exec time instead of a long-lived value sitting in env. Source-available (FCL-1.0), local-first, macOS and Linux, no account.
The category fix holds regardless of what you use: a secret that never enters the pipeline env can't leak through it. Hold secrets in a system designed for that boundary. Inject into specific processes at specific times. Audit every grant.
Sources· cited above, in one place
Stop handing the agent your real keys.
hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.
- Local, encrypted vault — no account, no cloud, no telemetry by default.
- Brokered run — agent gets a reference, the child process gets the value.
- Pre-commit + pre-push hooks catch managed values before they ship.
- Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.