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.
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
- Use
pull_requestfor CI. Fork pull requests get a read onlyGITHUB_TOKENand no other secrets. That is what you want for tests. - Never run pull request code under
pull_request_target. Checking out the head and then runningnpm ci,npm testor a build is the pwn request. The attacker editspackage.json, and it runs with your secrets. - Update actions/checkout. v7 (and the moved
@v4,@v6tags) refuse fork checkouts inpull_request_targetandworkflow_run. Old SHA pins andgit fetchare not covered. - Never put
${{ github.event.* }}text insiderun:. Pass it throughenv:and quote"$VAR". - 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.
- Add
permissions: contents: readat the top of every workflow and widen per job. - 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_targetworkflows 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_targetin 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_targetwithref: ${{ github.event.pull_request.head.sha }}orref: refs/pull/${{ github.event.pull_request.number }}/mergerepository: ${{ github.event.pull_request.head.repo.full_name }}git fetch,gh pr checkout, or an artifact from a fork'spull_requestrun that you then execute- The same thing under
issue_commentorworkflow_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@v4today gets the guard.uses: actions/checkout@<old SHA> # v4.2.2does not.- The guard only looks at what
actions/checkoutfetches. 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: truein 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 ofx-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 flagpull_request_targetwith the head checkout. The fixed files passed. - zizmor 1.31.1 (offline) reported
dangerous-triggersonpull_request_target,template-injectionon the title,excessive-permissionsfor the missingpermissions:block,artipackedfor the missingpersist-credentials: false, andunpinned-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_requesttopull_request_targetbecause "secrets are empty on fork PRs", then addingref: ${{ github.event.pull_request.head.sha }}. - Adding
allow-unsafe-pr-checkout: trueto 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_FILEsecret holding the whole.env, thenecho "$ENV_FILE" > .env && cat .envto 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, notpull_request_target - No
pull_request_target,workflow_runorissue_commentworkflow checks out, downloads or runs pull request code - No
allow-unsafe-pr-checkout: true, andactions/checkoutis a current release (v7.0.1 or a moved major tag), not an old SHA pin - No
${{ github.event.* }},${{ github.head_ref }}or${{ inputs.* }}insiderun:orgithub-scriptbodies; values go throughenv: - Top level
permissions: contents: readin 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: falseon 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
- GitHub Docs: Securely using pull_request_target (risks, pwn request shapes, default policy, allow-unsafe-pr-checkout)
- GitHub Docs: Secure use reference (security hardening for GitHub Actions)
- GitHub Docs: Events that trigger workflows (pull_request, pull_request_target, forks)
- GitHub Docs: Script injections
- GitHub Docs: Workflow syntax (permissions, default bash shell)
- GitHub Docs: Workflow commands (add-mask)
- GitHub Docs: Using secrets in GitHub Actions
- GitHub Docs: Use GITHUB_TOKEN for authentication in workflows
- GitHub Docs: Deployments and environments (required reviewers, environment secrets, plan limits)
- GitHub Docs: Managing environments for deployment
- GitHub Docs: Managing GitHub Actions settings for a repository (fork pull request workflows, workflow permissions)
- GitHub Docs: Approving workflow runs from forks
- GitHub Docs: Using workflow run logs (deleting logs)
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure Part 2: Untrusted input
- actions/checkout v7.0.1 README (fork pull request guard, persist-credentials)
- actions/checkout v7.0.1 source: unsafe-pr-checkout-helper.ts
- actions/runner source: SecretMasker.cs (exact match per line)
- actions/runner source: ValueEncoders.cs (base64 shifts, JSON, URI and other encodings)
- actions/runner source: HostContext.cs (encoders registered)
- actions/runner source: Worker.cs (secret value and each line added)
- actionlint
- zizmor audit rules
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.