HomeGuidespull_request_target and secret masking, tested

Guide

pull_request_target and secret masking, tested

How GitHub Actions secrets leak: pull_request_target pwn requests, script injection and log masking gaps. We ran checkout v7, the runner masker and the fixes.

17 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

Your Lovable, Bolt or Cursor project now has a .github/workflows folder. Maybe the AI tool added it to run tests, maybe you asked for a deploy on every merge. Then a preview build needed a Supabase key, and the quickest fix the tool found was pull_request_target plus a checkout of the pull request. That combination hands your secrets to anyone who can open a pull request.

This guide covers the ways GitHub Actions secrets actually leak from a repo: the pull_request_target "pwn request", script injection through ${{ github.event.pull_request.title }} and friends, and the limits of log masking. Then the fixes: a permissions: block, environments for deploy secrets, and a short checklist for a vibe-coded repo.

Tested on October 28, 2026. We ran the shipped dist/index.js of actions/checkout v7.0.1 against fake pull_request_target and workflow_run event payloads with Node 22, compiled the runner's own log masker from actions/runner (commit 4197182, October 27, 2026) with .NET SDK 8.0.427 and fed it 24 kinds of output built from fake secrets made at runtime, simulated a run: step with GitHub's default bash flags, and linted vulnerable and fixed workflows with actionlint 1.7.12 and zizmor 1.31.1. We did not run anything on GitHub or open pull requests against any repo. Every claim below is something we reproduced or a quote from the GitHub docs, GitHub Security Lab posts and source files linked at the end. VibeLocked is independent and not affiliated with GitHub.

The short version

  1. Use pull_request for CI. Fork pull requests get a read only GITHUB_TOKEN and no other secrets. That is what you want for tests.
  2. Never run pull request code under pull_request_target. Checking out the head and then running npm ci, npm test or a build is the pwn request. The attacker edits package.json, and it runs with your secrets.
  3. Update actions/checkout. v7 (and the moved @v4, @v6 tags) refuse fork checkouts in pull_request_target and workflow_run. Old SHA pins and git fetch are not covered.
  4. Never put ${{ github.event.* }} text inside run:. Pass it through env: and quote "$VAR".
  5. Masking is a safety net, not a boundary. The runner masks exact values and a few encodings. Transformed or split values print in clear text.
  6. Add permissions: contents: read at the top of every workflow and widen per job.
  7. Keep deploy secrets in an environment limited to main, with required reviewers where your plan allows it.

pull_request vs pull_request_target

From GitHub's events reference: "With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. The GITHUB_TOKEN has read-only permissions in pull requests from forked repositories."

pull_request_target flips that. GitHub's Securely using pull_request_target page: "the job receives the base repository's GITHUB_TOKEN and access to repository and organization secrets." It stays safe because the workflow file, and any actions/checkout without a ref, come from your default branch. "No code from the fork is executed by default."

pull_request (from a fork) pull_request_target
Workflow file comes from the pull request merge commit your default branch
Default checkout the pull request merge commit your default branch
Repository secrets not passed available
GITHUB_TOKEN read only your repo's token, with your default permissions

Two more rules from the docs that matter for small repos:

  • Private repos are different. Fork pull request workflows in a private repo run only when you enable them. The repository Actions settings also have "Send write tokens to workflows from pull requests" and "Send secrets to workflows from pull requests". Leave both off unless you know why you need them (Managing GitHub Actions settings).
  • Public repos: first-time contributors need approval. By default a maintainer must approve workflow runs for first-time contributors. The docs warn that someone who once got a typo fix merged no longer counts as first-time (Approving workflow runs from forks). Approval is not a code review of what the workflow will run.
  • Cache. pull_request_target workflows have read only access to the default branch cache, to limit cache poisoning.
  • Public repos get a new default policy. The pull_request_target page says GitHub adds a default policy that blocks pull_request_target in public repositories, currently in evaluate mode, and that "On December 2, 2026, GitHub will enforce the default policy" for affected repositories. It "Does not apply to private or internal repositories."

The pwn request

This is GitHub's own insecure example, from the same page:

# INSECURE. Provided as an example only.
on:
  pull_request_target:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Test
        run: make test

The docs explain the trap: "The checkout step alone does not execute untrusted code." The next step does. "Here, make test executes a Makefile taken from the pull request head." For a JavaScript app the same is true of npm ci (install scripts), npm test, npm run build, a vite.config.ts or a test file. GitHub Security Lab's Part 1: Preventing pwn requests puts it this way: attackers "can achieve code execution even before the actual build happens", since npm packages "may have custom preinstall and postinstall scripts".

The shapes to search your .github/workflows for, from the docs:

  • pull_request_target with ref: ${{ github.event.pull_request.head.sha }} or ref: refs/pull/${{ github.event.pull_request.number }}/merge
  • repository: ${{ github.event.pull_request.head.repo.full_name }}
  • git fetch, gh pr checkout, or an artifact from a fork's pull_request run that you then execute
  • The same thing under issue_comment or workflow_run. "Pwn requests are also not unique to pull_request_target."

What actions/checkout v7 now refuses

actions/checkout v7 added a guard: "checkout now refuses to check out fork pull request code by default when the workflow is triggered by pull_request_target or workflow_run." You opt out with allow-unsafe-pr-checkout: true. We ran the released v7.0.1 bundle (commit 3d3c42e) on our machine with fake event payloads for a fork pull request, no network:

Event and checkout input Result
pull_request_target, ref: fork head SHA refused
pull_request_target, ref: refs/pull/7/merge refused
pull_request_target, repository: the fork refused
workflow_run from a fork pull_request, ref: head SHA refused
pull_request_target, no ref (your default branch) allowed
pull_request_target, head SHA, allow-unsafe-pr-checkout: true allowed
pull_request_target, same repo pull request (not a fork) allowed
pull_request, fork head SHA allowed (no secrets in that event)

Refused runs failed with "Refusing to check out fork pull request code from a 'pull_request_target' workflow." The allowed ones went on to git fetch, which failed only because our fake server did not exist.

Then we ran the same fork head SHA case against v4.2.2 (commit 11bd719, October 2024), an older release a workflow might still pin by SHA. It did not refuse and went straight to fetching. In the actions/checkout repo, the v2, v3, v4, v5 and v6 major tags now point at commits dated July 16, 2026 that include the guard (v4 now points at v4.4.0, v6 at v6.1.0). Older exact versions such as v4.2.2, v4.3.1, v5.0.0 and v6.0.2 do not have it. So:

  • uses: actions/checkout@v4 today gets the guard. uses: actions/checkout@<old SHA> # v4.2.2 does not.
  • The guard only looks at what actions/checkout fetches. The docs: it does not cover "fetching code with git fetch or gh pr checkout, or running a downloaded artifact."
  • Seeing allow-unsafe-pr-checkout: true in a diff from your AI tool is a stop sign. The input "is intentionally named to be easy to spot in code review".

The safe shape

If tests need no secrets, which is true for most vibe-coded repos, use pull_request:

name: CI
on:
  pull_request:
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: npm ci && npm test

If a job really needs a secret to process a fork's pull request, Security Lab's pattern is two workflows: the untrusted build runs on pull_request and uploads results as an artifact, and a second workflow on workflow_run downloads it with write access. Its warning: "Artifacts resulting from untrusted PR data are themselves untrusted and should be treated as such when handled in privileged contexts." Read them as data. Never execute them.

Script injection: the title is code

GitHub's script injections page: the run step "executes within a temporary shell script on the runner. Before the shell script is run, the expressions inside ${{ }} are evaluated and then substituted with the resulting values". Values attackers control "typically end with body, default_branch, email, head_ref, label, message, name, page_name,ref, and title". Branch names count: the docs give zzz";echo${IFS}"hello";# as a valid branch name.

We simulated the substitution and ran the result with the default shell from the workflow syntax docs, bash --noprofile --norc -eo pipefail, on bash 5.2.37. The step was echo "Thanks for ${{ github.event.pull_request.title }}", with a fake token in the environment:

Pull request title Inline ${{ }} printed env: TITLE + "$TITLE" printed
Fix typo in README Thanks for Fix typo in README same
x"; echo "INJECTED: this line ran as $(whoami) with FAKE_TOKEN=$FAKE_TOKEN"; echo " Thanks for x, then INJECTED: this line ran as box with FAKE_TOKEN=fake_not_a_real_token (box is our test machine user) the title as plain text
Update $(echo INJECTED-VIA-SUBSHELL) Thanks for Update INJECTED-VIA-SUBSHELL the title as plain text

The injected title ran a command and read an environment variable. In a real workflow that variable is your secret, and the next command could be a curl to the attacker's server. Security Lab's Part 2: Untrusted input walks through exactly that, including stealing the GITHUB_TOKEN before the job ends. The $(...) case runs even inside double quotes, because the shell sees it as part of the script.

The fix GitHub recommends:

      - name: Greet
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: echo "Thanks for $TITLE"

"the value of the ${{ github.event.pull_request.title }} expression is stored in memory and used as a variable, and doesn't interact with the script generation process." The same goes for actions/github-script: pass values in env: and read process.env, do not paste ${{ }} into the script body.

How log masking works, and where it stops

GitHub replaces secret values in logs with ***. The Secure use reference is direct about the limits: "Because there are multiple ways a secret value can be transformed, automatic redaction is not guaranteed." It also says not to store structured data like JSON as one secret, to register derived values with ::add-mask::, and that if a secret reaches a log "you should delete the log and rotate the secret".

In the runner source, HostContext.cs registers 11 value encoders with the masker: three base64 variants (plain and shifted by one and two bytes, for strings like user:secret), command line, expression, JSON, URI, XML escapes, a double quote trim and two PowerShell cases. Worker.cs adds each secret's full trimmed value plus each of its lines. The masker then does an exact substring match per line of output. We compiled that code and wired it the same way, then printed 24 kinds of output. All secrets were fake values generated for the run.

Output in the step Masked?
echo "$SECRET", or the secret inside a longer line yes
bash -x trace of a command with the secret yes
printf %s "$SECRET" | base64 -w0 (30, 31 and 32 characters) yes
printf %s "ci:$SECRET" | base64 -w0 (basic auth header) yes, the base64 of the user: prefix stays visible
URL encoded or JSON escaped secret with /, +, = and " yes
Multiline private key printed whole, or one line of it base64 encoded yes
echo "$SECRET" | base64 -w0 (adds a newline), 30 characters yes
echo "$SECRET" | base64 -w0, 31 or 32 characters no
base64 of $SECRET:suffix no
base64 with the default 76 column wrap (64 character secret) no
hex dump, reversed, uppercased no
echo "${SECRET:0:15}"; echo "${SECRET:15}" (split) no
characters separated by spaces no
one field printed from a secret stored as a JSON blob no
the same JSON blob pretty printed no

So the masker does better than the common "base64 breaks masking" line suggests, but one stray newline was enough to defeat it for two of three secret lengths. And Security Lab's Part 2 says plainly that log redaction "is not a true security boundary since it is impossible to protect from intentional logging". Code that runs with your secrets can print them in pieces, or simply send them somewhere. Masking only protects you from accidents in code you trust.

Practical rules:

  • Store each credential as its own secret, never a JSON blob. If a tool needs a JSON file, build it in the step and mask each field.
  • If you derive something from a secret (a JWT, a base64 auth header, a connection string), run echo "::add-mask::$DERIVED" before anything prints it. actions/checkout does this itself: our v4.2.2 run printed ::add-mask:: with the base64 of x-access-token: plus the token before it used it.
  • Do not turn on set -x, debug logging or verbose flags in steps that hold secrets unless you have to. Exact values are masked, transformed ones are not.
  • If a secret did show up, delete the run log and rotate the secret. Then follow the first hour after an API key leaks.

permissions: least privilege for GITHUB_TOKEN

The Secure use page: "It's good security practice to set the default permission for the GITHUB_TOKEN to read access only for repository contents." Repositories have a default of either read and write for all permissions (the "permissive" setting) or read for contents and packages (the "restricted" setting), under Settings, Actions, General, Workflow permissions. Pick the restricted one, then set it in each workflow too, so the file is safe even if the setting changes:

permissions:
  contents: read

jobs:
  label:
    permissions:
      contents: read
      pull-requests: write

From the workflow syntax reference: "If you specify the access for any of these permissions, all of those that are not specified are set to none." permissions: {} turns everything off. For a fork's pull request you can add or remove read permissions, but "typically you can't grant write access" unless an admin enabled write tokens for pull requests. More on scoping tokens in least privilege API keys.

Also set persist-credentials: false on actions/checkout unless a later step pushes. Security Lab notes checkout keeps the token on disk by default, and the checkout v6 README says it now stores it "in a separate file under $RUNNER_TEMP" instead of .git/config. Either way, any later step can read it.

Environments: keep deploy secrets out of pull request jobs

Put production deploy keys (Vercel, Cloudflare, Supabase access tokens, a Supabase secret key for migrations) in an environment instead of repository secrets. From the deployments and environments reference: "Secrets stored in an environment are only available to workflow jobs that reference the environment. If the environment requires approval, a job cannot access environment secrets until one of the required reviewers approves it."

name: Deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: npm ci && npm run deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Set the environment's deployment branches to main only, so a run from any other branch cannot deploy to it. Turn on required reviewers (up to six) and "Prevent self-review" if you can. Check your plan first, because the docs are specific:

  • "If you are on a GitHub Free, GitHub Pro, or GitHub Team plan, required reviewers are only available for public repositories." Wait timers are the same.
  • "Users with GitHub Free plans can only configure environments for public repositories."
  • "Organizations with GitHub Team and users with GitHub Pro can configure environments for private repositories", including environment secrets and deployment branches.

So a private repo on a free personal account gets no environments at all. Keep deploy secrets out of any workflow that runs on pull requests, and deploy from your host's Git integration or a push to main workflow only.

What the linters caught

We linted a vulnerable workflow (pull_request_target, actions/checkout@v4 with the head SHA, npm ci && npm test with a Supabase secret in env, and an inline title) and the two fixed workflows above:

  • actionlint 1.7.12 flagged the inline title: "github.event.pull_request.title" is potentially untrusted. It did not flag pull_request_target with the head checkout. The fixed files passed.
  • zizmor 1.31.1 (offline) reported dangerous-triggers on pull_request_target, template-injection on the title, excessive-permissions for the missing permissions: block, artipacked for the missing persist-credentials: false, and unpinned-uses. The fixed files had no findings.

Run one of them on every change to .github/workflows. GitHub's docs also point to CodeQL for Actions workflows and OpenSSF Scorecard.

Common AI generated mistakes

  • Switching pull_request to pull_request_target because "secrets are empty on fork PRs", then adding ref: ${{ github.event.pull_request.head.sha }}.
  • Adding allow-unsafe-pr-checkout: true to get past the new checkout error.
  • run: echo "${{ github.event.pull_request.title }}", ${{ github.head_ref }} in a branch name script, or ${{ github.event.comment.body }} in a ChatOps bot.
  • One ENV_FILE secret holding the whole .env, then echo "$ENV_FILE" > .env && cat .env to debug.
  • No permissions: block, on a repo whose default is still read and write.
  • Deploy keys as repository secrets, available to every workflow, instead of an environment.
  • Third party actions on a moving tag. The Secure use page says to pin to a full commit SHA, or use a workflow dependency lockfile where you can.

Checklist

  • Every workflow that tests or builds pull requests uses pull_request, not pull_request_target
  • No pull_request_target, workflow_run or issue_comment workflow checks out, downloads or runs pull request code
  • No allow-unsafe-pr-checkout: true, and actions/checkout is a current release (v7.0.1 or a moved major tag), not an old SHA pin
  • No ${{ github.event.* }}, ${{ github.head_ref }} or ${{ inputs.* }} inside run: or github-script bodies; values go through env:
  • Top level permissions: contents: read in every workflow, wider only per job
  • Repository default workflow permissions set to read only
  • Private repo: "Send write tokens" and "Send secrets" to fork pull request workflows are off
  • persist-credentials: false on checkout unless the job pushes
  • Each credential is its own secret; derived values are registered with ::add-mask::
  • Deploy secrets live in an environment limited to main, with required reviewers where your plan allows
  • actionlint or zizmor runs on changes to .github/workflows
  • A secret that reached a log was rotated and the log deleted

Then run the rest of the ship checklist for a vibe-coded app.

Copy-paste prompt for your AI coding tool

Audit every file in .github/workflows in this repo. Do not print or ask for any secret values. For each workflow list its triggers, its permissions block, every secret it reads, and every actions/checkout step with its ref, repository and version. Flag: any pull_request_target, workflow_run or issue_comment workflow that checks out, fetches, downloads or runs pull request code; any allow-unsafe-pr-checkout: true; any ${{ github.event.* }}, ${{ github.head_ref }} or ${{ inputs.* }} used directly inside run: or a github-script body; missing top level permissions; checkout without persist-credentials: false in jobs that do not push; secrets stored as one JSON or .env blob; deploy secrets used outside an environment. Propose fixes: switch CI to pull_request, move untrusted values into env: and quote them, add permissions: contents: read, move deploy secrets to a production environment limited to main. Show the diff before applying it. Do not add pull_request_target or allow-unsafe-pr-checkout to make anything pass.

More prompts: security review prompt for AI coding tools. To keep keys out of git in the first place: Gitleaks pre-commit and CI.

Check what your repo and app expose with LeakCheck

LeakCheck scans any live app URL or public GitHub repo, read only. On a repo it looks for committed keys such as Supabase service role, Stripe, OpenAI and AWS keys. It does not audit workflow triggers, run your Actions or read your repository secrets, so the workflow review above is still yours to do. If a key did leak through a log or a commit, rotate it first, then clean up with remove a leaked API key from GitHub history.

Sources

Questions

What is the difference between pull_request and pull_request_target?

pull_request runs the workflow from the pull request's merge commit. For a pull request from a fork it gets a read only GITHUB_TOKEN and no other secrets. pull_request_target runs the workflow file from your default branch, with your repository's GITHUB_TOKEN and access to repository and organization secrets. It is safe only as long as it never runs code from the pull request.

What is a pwn request?

A pull_request_target (or workflow_run, or issue_comment) workflow that checks out the pull request's code and then runs it, for example npm ci, npm test or make. The workflow file comes from your branch, but the package.json scripts, Makefile and dependencies come from the attacker's fork, and they run with your secrets and token. GitHub Security Lab described the pattern in 2021.

Does actions/checkout block pwn requests now?

Partly. actions/checkout v7, and the moved v2 to v6 major tags, refuse to check out fork pull request code in pull_request_target and workflow_run unless you set allow-unsafe-pr-checkout: true. In our test v7.0.1 refused a fork head SHA, refs/pull/7/merge and the fork repository. A workflow pinned to the older v4.2.2 commit did not refuse. The guard also does not cover git fetch, gh pr checkout or downloaded artifacts.

Can a pull request title run code in my GitHub Actions workflow?

Yes, if a run step puts it inside ${{ }}. The expression is replaced with the title before the shell starts, so a title like x"; echo INJECTED; echo " ends your string and adds a command. In our simulation that title ran a command and read an environment variable. Passing the title through env: and using "$TITLE" printed it as plain text instead.

Does GitHub mask base64 encoded secrets in logs?

Sometimes. The runner also masks the base64 form of each secret, including the shifted forms used in user:secret strings. In our test against the runner's masker, printf secret | base64 was masked for all three lengths, but echo secret | base64, which adds a newline, was masked only for one length out of three. Wrapped base64, hex, reversed, uppercased or split values were not masked at all.

Do environment required reviewers work on a private repository?

Not on most plans. GitHub's docs say required reviewers and wait timers are only available for public repositories on GitHub Free, Pro and Team. On GitHub Free you cannot create environments in a private repository at all. Pro and Team can use environments, environment secrets and deployment branch rules in private repos, which still keeps a deploy secret out of pull request jobs.