LEAKCADE

GitHub Actions secrets: how AI-built repos leak them and how to lock CI down

Updated 8 min read

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 a pwn request leaks your secretsA stranger's pull request triggers a pull_request_target job that checks out the PR code. npm install runs the attacker's script and exposes secrets and the write token.FORKStrangeropens a pullrequestRUNpull_request_targetstarts a jobCODEJob checksout andbuilds PRcodeNPMnpm installruns theirscriptLEAKSecrets andwrite tokenexposedFORKStranger opens a pull requestRUNpull_request_target starts a jobCODEJob checks out and builds PR codeNPMnpm install runs their scriptLEAKSecrets and write token exposed
The privileged trigger plus a checkout of PR code is the dangerous combination.

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.

SecretsWrite access
pull_request from a forkNoNo
pull_request from the same repoYesYes
pull_request_targetYesYes
workflow_runYesYes

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.

BEFOREUnsafe: privileged trigger runs PR code
# 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 }}
AFTERSafe: plain pull_request, no secrets
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.json
The left workflow lets any fork PR run code next to your secrets and a write token. Replace the placeholder SHA with one from the action's own repository.

The 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.

BEFOREUnsafe: issue title inside the script
on:
  issues:
    types: [opened]
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: echo "New issue: ${{ github.event.issue.title }}"
AFTERSafe: title goes through an env variable
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"
On the left, the title becomes part of the shell script. On the right, it stays data.

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.

  1. 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 an env: 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.

  2. Limit GITHUB_TOKEN permissions

    Add permissions: contents: read at the top of each workflow. Raise it for single jobs that need more, such as issues: write. Without a permissions: block the token gets your repo or organization default, so check that default in your Actions settings.

  3. Replace pull_request_target

    Use plain pull_request to build and test PRs. If you only need to label or comment, pull_request_target is fine as long as it never checks out or runs the PR's code.

  4. 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 the run: text.

  5. 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, like uses: 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.

  6. Gate deploy keys behind an environment

    Create an environment (a named deployment target with its own secrets and approval rules) such as production under Settings, then Environments. Store the key with gh secret set STRIPE_SECRET_KEY --env production, add environment: production to 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/workflows

Bad results:

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:

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.

FIX PROMPT
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

  1. GitHub Docs: Secure use reference
  2. GitHub Docs: Using secrets in GitHub Actions
  3. GitHub Docs: Use GITHUB_TOKEN for authentication in workflows
  4. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.