# What makes a GitHub Actions workflow dangerous

> Five mistakes in a workflow file let a stranger run commands or read secrets: risky triggers, injected text, loose pins, a wide token and leaked values.

Published 2026-10-27 · https://aiskills402.com/blog/github-actions-workflow-risks

A GitHub Actions workflow becomes dangerous when it lets text or code written by a stranger reach a place that has power: a shell, a checked-out branch with secrets in scope, or a token that can write to the repository. Nearly all serious incidents here fit one of five patterns, and all five can be spotted by reading the YAML file alone. This guide walks through them in the order a reviewer should check, shows what the safe version looks like, and ends with the cases where the advice is less useful than it sounds.

## Why the workflow file is the thing to read

A workflow is a program that runs on a machine you pay for, with credentials you stored, triggered by events that other people create. Anyone can open an issue, a pull request or a comment on a public repository, so the trigger list is also a list of who can start your program. The code of your application may be perfectly clean while the pipeline that builds it hands out the keys.

Reviews of the application code do not catch this, because the mistake is not in what the code does but in what the pipeline lets an outsider influence. That is why it deserves its own pass, short and mechanical.

## 1. Outsider text pasted into a shell step

The most common mistake is writing an expression such as the title of a pull request directly inside a `run:` block. GitHub expands the expression before the shell sees the script, so a title that contains quotes and a command becomes part of the script. The Security Lab lists the fields an attacker controls: titles and bodies of issues and pull requests, comment bodies, commit messages, author names and emails, and the branch name ([untrusted input write-up](https://securitylab.github.com/resources/github-actions-untrusted-input/)). Branch names are the sneaky one, because people treat them as identifiers, not as free text.

The documented fix is to move the expression into an environment variable on the step and read it from the shell as a quoted variable, so the value is data and never code; the other fix is to hand the value to a small action as an argument ([GitHub Docs](https://docs.github.com/en/actions/reference/security/secure-use)). The reviewer asks one thing: does any `${{ ... }}` inside a `run:` block contain a field from the list above? If yes, that is a finding.

The safe look-alike is worth knowing too. The same expression inside an `env:` block, followed by a quoted `"$TITLE"` in the script, is the recommended form and must not be flagged.

## 2. Privileged triggers that run the pull request's code

Out of the box, a run kicked off from a forked contribution is given no secrets and only a token that can read. The `pull_request_target` trigger exists to relax that, and `workflow_run` is related. GitHub states that using either one together with a checkout of the untrusted pull request exposes the repository, because these workflows share the privileged cache and may have write access and secrets ([GitHub Docs](https://docs.github.com/en/actions/reference/security/secure-use)). The Security Lab calls the combination of that trigger and an explicit checkout of the contributor's code a dangerous practice that can end in a repository takeover ([pwn requests](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/)).

Look for two things in the same job: the privileged trigger, and a checkout whose `ref` points at the pull request head or whose code is then built, tested or installed. A labeller or commenter that never checks out the contributor's files is fine. A job that runs `npm install` on the contributor's branch is not, because a package script is code.

The safer design splits the work: an unprivileged workflow builds and tests the untrusted code and uploads the result, and a separate privileged one only reads that result as data.

## 3. Third-party actions that can change under you

A line such as `uses: owner/action@v2` means "run whatever that tag points at today". Tags and branches can be moved by the owner, or by someone who has taken over the owner's account. GitHub recommends pinning to a full commit SHA, which an attacker could not forge without breaking the hash, and says to confirm that the SHA belongs to the action's own repository, not to a fork ([GitHub Docs](https://docs.github.com/en/actions/reference/security/secure-use)).

A realistic review does not demand a hash for everything. Actions published by the platform itself carry a different level of trust than a small action from an unknown account, and the first group is usually acceptable on a version tag. The finding is an unfamiliar publisher on a movable reference, especially in a job that holds secrets. Our piece on [installing unvetted agent skills](/blog/agent-skills-security-risks) rests on the same logic: whatever you run on trust can be swapped later without you noticing.

## 4. A token with wider reach than the task

Each run receives an automatic token. GitHub tells authors to give it only the permissions the work requires, and notes that anyone with write access to the repository can read every secret stored in it ([GitHub Docs](https://docs.github.com/en/actions/reference/security/secure-use)). A workflow with no `permissions:` block inherits whatever default the repository or organization chose, which may be broad; a block that says write for everything defeats the point.

The review is two questions. Is there an explicit block, either file-wide or on each job? And does each granted scope match what the steps do? A job that only runs tests needs read access to the contents and nothing else. When a finding here sits next to injection or a privileged trigger, it decides how bad the break-in is: a stolen token that can only read is an annoyance, a stolen write token can push code.

## 5. Secrets printed, or given to foreign code

Secrets are masked in logs only on a best-effort basis; GitHub warns that redaction is not guaranteed because a value can be transformed before it is printed ([GitHub Docs](https://docs.github.com/en/actions/reference/security/secure-use)). So `echo` of a secret, or a debug step that dumps the whole environment, is a leak even if the log looks starred out. The other form is giving a secret to a step that executes somebody else's code: a loose action, a build script taken from a contribution, a container pulled from an unknown registry.

The rule of thumb is to give each secret to the narrowest step that needs it, as an environment variable on that step, never at workflow level, and never in a job that also executes outside code.

## A short order for the review

1. Read the `on:` list and mark which triggers let outsiders start the run.
2. Scan every `run:` block for expressions that carry outsider text.
3. For each privileged trigger, check whether contributor code is checked out or executed.
4. List every `uses:` line and note the publisher and the kind of reference.
5. Check the `permissions:` block and each place a secret appears.

Five minutes on a normal file is enough, and the output should be a short list of findings, each with a place and a fix, not a general essay about security.

## Where our skill fits

Our skill [github-actions-security-review](https://aiskills402.com/skills/github-actions-security-review) applies this list to a single workflow file. It reports only the five classes above, one line per finding with the location and the fix, and says so plainly when nothing turns up. It deliberately skips the safe look-alikes, such as outsider text passed through an environment variable, so the list stays short enough to act on. You can use it before merging a change to a workflow, or on every pipeline in a repository. All our skills are in the store at https://aiskills402.com, one file each.

## When this does not apply

- **Private repositories with a closed team.** If only people you trust can open pull requests and issues, injection and privileged-trigger risks shrink a lot, though a compromised member account or a leaked token brings them back.
- **Self-hosted runners and other platforms.** Runners that persist between jobs and sit inside a network have extra risks that a read of the YAML cannot show. This guide also says nothing about other CI systems.
- **What the file cannot tell you.** Repository and organization settings, such as the default token scope or which actions are allowed, live outside the YAML. A clean file can still run under a loose default, and a file with no `permissions:` block cannot be judged without checking that setting.
- **A review is a snapshot.** It reads one file at one time. A reusable workflow called from the file, or an action that changes after you looked, is outside what you saw.
- **Not every finding is exploitable.** A movable tag on a trusted publisher in a job without secrets is a weak finding. Rank by what an attacker would gain, not by how many lines match.
- **Our check is a reading, not a proof.** A skill can miss an unusual construct, or report something harmless. Treat its list as input to a human decision, and test fixes by trying the attack on a throwaway repository.
