GitHub Actions secrets: how AI-built repos leak them and how to lock CI down
GitHub Actions secrets stay private only if your workflows never hand them to code or text you don't control. Four patterns break that: pull_request_target (a trigger that runs with your secrets) with a checkout of pull request (PR) code, untrusted text in shell scripts, a loose GITHUB_TOKEN, and unpinned third-party actions. Fix those and you close the most common ways an outsider's pull request or issue can reach your keys.
QUICK CHECKLIST
- Keep keys in secrets, never in workflow files
- Use pull_request, not pull_request_target, to build PRs
- Pass issue and PR text through env vars, never inline
- Set GITHUB_TOKEN to contents: read, raise it per job
- Pin third-party actions to a full commit SHA
- Put deploy keys in an environment with reviewers
What GitHub Actions secrets protect and what can reach them
GitHub Actions secrets are encrypted values you store in repository, environment or organization settings. Your workflow reads them with ${{ secrets.NAME }} and uses them to deploy, publish a package or call an API from CI.
If you build solo with an AI tool, you probably have one repo, one deploy key and a workflow file you never read. That's fine until someone else's text or code reaches that workflow, because then everything the job can read is in play.
Anyone with write access to your repository can read all of its secrets. A compromised action in your workflow would have access to all secrets configured on the repository, and it may be able to use the GITHUB_TOKEN (a temporary access token GitHub creates for each workflow run) to write to it.
How attackers reach your CI secrets
Every attack here gets the attacker's code or text into a job that holds your secrets. There are four routes.
- A privileged trigger plus PR code.
pull_request_targetruns with write permission to your repo and access to its secrets. If the workflow also checks out the pull request's code and runsnpm install, the attacker decides what runs. GitHub Security Lab calls these pwn requests (a pull request that takes over your workflow). The idea resembles an npm supply chain attack: untrusted code runs where your secrets are. - Script injection. If you write
${{ github.event.issue.title }}directly inside arun:step, the value is pasted into the shell script before it runs. Anyone who can open an issue can put shell syntax in the title. - A compromised third-party action. Jobs in a workflow can interact with each other, so one bad action can reach the secrets of every job. A tag like
@v1can be moved or deleted if someone gets into the action's repo. - Secrets typed into the workflow file. Anyone who can read the repo can read them, and a public repo means everyone. That's a plain leaked API key sitting in YAML.
| Secrets | Write access | |
|---|---|---|
| pull_request from a fork | No | No |
| pull_request from the same repo | Yes | Yes |
| pull_request_target | Yes | Yes |
| workflow_run | Yes | Yes |
Based on GitHub Security Lab's pwn requests post. Actual token write access also depends on your repo/org default workflow permissions and any permissions: block.
Unsafe and safe workflows side by side
AI tools often produce workflows like the left sides below.
The first builds a PR with the privileged trigger and hands it a secret.
# INSECURE
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm test
env:
API_KEY: ${{ secrets.API_KEY }}on: pull_request
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@FULL_40_CHAR_SHA # v4
with:
persist-credentials: false
- run: npm ci && npm test # needs package-lock.jsonThe fixed version gives the PR build no secrets and no write access. Your tests have to pass without live keys.
The second echoes an issue title.
on:
issues:
types: [opened]
jobs:
triage:
runs-on: ubuntu-latest
steps:
- run: echo "New issue: ${{ github.event.issue.title }}"on:
issues:
types: [opened]
permissions:
contents: read
jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Log title
env:
TITLE: ${{ github.event.issue.title }}
run: echo "New issue: $TITLE"With the env variable, the title stays data. Quote it in the shell.
How to lock down GitHub Actions secrets, step by step
Work through your workflow files in this order.
Move keys into secrets
Run
gh secret set STRIPE_SECRET_KEY, paste the value when asked, and reference it as${{ secrets.STRIPE_SECRET_KEY }}in anenv:block. Create one secret per value, because redaction relies on exact matches and a JSON blob can slip past it. Treat any key that was ever typed into a workflow file as leaked and rotate it.Limit GITHUB_TOKEN permissions
Add
permissions: contents: readat the top of each workflow. Raise it for single jobs that need more, such asissues: write. Without apermissions:block the token gets your repo or organization default, so check that default in your Actions settings.Replace pull_request_target
Use plain
pull_requestto build and test PRs. If you only need to label or comment,pull_request_targetis fine as long as it never checks out or runs the PR's code.Put untrusted text in env vars
For issue titles, PR titles, bodies and branch names, set
env: TITLE: ${{ ... }}and use"$TITLE"in the script. Never put the expression inside therun:text.Pin actions to a commit SHA
GitHub's strongest option is a workflow dependency lockfile, which also covers actions that your actions call. If you can't adopt one, write the full-length commit SHA (the 40-character ID of one exact version of the action's code) after the
@, with the version in a comment, likeuses: actions/checkout@FULL_40_CHAR_SHA # v4. A SHA pin does not cover what that action calls internally. Copy the SHA from the action's own repository, not from a fork.Gate deploy keys behind an environment
Create an environment (a named deployment target with its own secrets and approval rules) such as
productionunder Settings, then Environments. Store the key withgh secret set STRIPE_SECRET_KEY --env production, addenvironment: productionto the deploy job and require reviewers. Check your plan first, because required reviewers for private repositories aren't available on GitHub Free.
Also list .github/workflows in a CODEOWNERS file (a file that names who must approve changes to given paths) so workflow changes need a reviewer. If your cloud provider supports OpenID Connect (short-lived logins issued per run), use it instead of long-lived cloud credentials.
Split privileged work from untrusted PR code
To comment on every PR, GitHub Security Lab describes a safe pattern. One workflow runs on pull_request with no secrets and a read-only token, and a second runs on workflow_run with the write access.
The first workflow builds the PR and saves a small result as an artifact (a file a job uploads so later workflows can download it). The second downloads it and posts the comment. Treat that artifact as untrusted input: reading a PR number from it is fine, but running a binary built from the PR inside the privileged workflow is a vulnerability.
Restricting pull_request_target to PRs with a safe to test label is only a temporary fix, because an attacker can push changes after the label is applied but before the job starts.
Test it yourself
Run all of this on your own repositories only. Use a throwaway repo for the injection test.
These greps find privileged triggers, risky expressions, unpinned actions and hardcoded-looking keys.
grep -rnE 'pull_request_target|workflow_run' .github/workflows
grep -rnE 'github\.event|github\.head_ref' .github/workflows
grep -rnE 'uses: [^ ]+@' .github/workflows | grep -vE '@[0-9a-f]{40}'
grep -rnEi '(key|token|secret|password)[a-z_]*:[[:space:]]*"?[A-Za-z0-9_.-]{8,}' .github/workflowsBad results:
- a
pull_request_targethit next to a checkout ofgithub.event.pull_request.head.sha - a
github.eventorgithub.head_refexpression insiderun: - a hardcoded value
A good result is no hits, or hits you checked one by one.
To test script injection, put the unsafe issue-title workflow in the throwaway repo and open an issue titled $(echo INJECTED). If the run log says New issue: INJECTED, the shell executed your title. In the fixed version the log prints the title as literal text.
To test fork pull requests, add a step with env: API_KEY: ${{ secrets.API_KEY }} and run: test -n "$API_KEY" && echo present || echo empty. Then open a PR from a second account's fork and read the run log:
empty: the secret was withheld, which is what you want.present: a privileged trigger (or the private-repo setting that sends secrets to fork PR workflows) is handing secrets to fork PRs.
Fix prompt you can paste
Paste this into Cursor or Claude Code at the repo root. Read the diff afterwards: a change that quietly widens permissions is easy to miss.
Audit every file in .github/workflows/ for security problems.
Report each finding as file:line, fix it, and explain the change in one sentence.
Look for:
1. Hardcoded keys, tokens or passwords. Replace them with ${{ secrets.NAME }}
and list the secret names I must create. Never print secret values.
2. pull_request_target or workflow_run combined with actions/checkout of
github.event.pull_request.head.sha or head.ref, or any step that runs
install, build or test on that code. Switch build/test jobs to
pull_request with permissions: contents: read. If a job must build the PR
AND comment or label, split it into an unprivileged pull_request workflow
that uploads an artifact and a workflow_run workflow that comments. If a
job only comments or labels, it may keep pull_request_target but must not
check out or run PR code.
3. ${{ github.event.* }} or ${{ github.head_ref }} inside run: or script:
blocks. Move each into an env: variable and use a quoted shell variable.
4. Missing permissions: blocks. Add contents: read at workflow level and
raise it per job only where needed.
5. Third-party actions referenced by tag or branch. Pin them to a full commit
SHA with a version comment. Do not invent SHAs: list the ones I need to
look up in each action's own repository.
6. Deploy jobs using production secrets without an environment: key. Add
environment: production to the job and tell me to enable required
reviewers in Settings.
7. actions/checkout steps without persist-credentials: false. Add it unless
the job needs to push.
8. workflow_run jobs that execute code from a downloaded artifact. Flag them
and explain the risk.
Do not change what the workflows do beyond these fixes. End with a summary
table of findings and fixes.For a second opinion on the whole repo, try Leakcade.
Further reading
- GitHub Docs: Secure use reference
- GitHub Docs: Using secrets in GitHub Actions
- GitHub Docs: Use GITHUB_TOKEN for authentication in workflows
- GitHub Security Lab: Preventing pwn requests
FAQ
Are GitHub Actions secrets available to pull requests from forks?
Not by default. With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. The pull_request_target and workflow_run triggers run with secrets, so they need care.
Is pull_request_target safe to use?
It's safe for jobs that only label or comment and never check out or run the PR's code. Combined with an explicit checkout of an untrusted PR, it can lead to repository takeover. GitHub's docs say to avoid it when it isn't necessary.
How do I pin actions to a commit SHA?
Put the full-length commit SHA after the @ in uses: and add the version as a comment. Copy the SHA from the action's own repository, not a fork.
What should I do if a secret shows up in a workflow log?
Delete the log and rotate the secret. Redaction is done by the runner and only works if it finds an exact match, so transformed or structured values can slip through. See how to rotate API keys after a breach.
Can everyone with access to my repo read its secrets?
Not everyone, but anyone with write access to the repository can read all secrets configured in it, so only give write access to people you trust. Put production keys in an environment with required reviewers so a job can't read them until someone approves.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms