A self-hosted runner executes whatever code a pipeline hands it, on a machine you own, inside your network, often holding the credentials that deploy to production. Runners and build agents (GitHub Actions self-hosted runners, GitLab Runner, Jenkins agents, Azure Pipelines self-hosted agents) are some of the most privileged non-human identities in an engineering estate, yet they are usually run as plumbing rather than as identities. This guide covers the operational side the wider CI/CD guides only touch: runner models, registration, runner identity, executor settings, network placement and monitoring, for platform, DevOps and security engineers who run their own build fleet. For pipeline design, workflow injection and source control controls, see the CI/CD Security Guide.
Key takeaways
- Anyone who can trigger a job can run code on the runner. GitLab states that any user with the Developer role on a project can run code on its runners, and GitHub warns that anyone able to fork and open a pull request may be able to compromise a self-hosted runner.
- Single-use beats cleanup. A runner that is created for one job and destroyed afterwards removes most persistence and cross-job theft, but GitHub notes that reused hardware still needs automation to guarantee a clean start.
- The runner's own identity matters as much as the job's. A runner process running as root, as a domain account or as a cloud-wide identity gives every job that identity; run it as a dedicated low-privilege account.
- Scope runners to trust levels. Use runner groups, protected runners, separate agent pools and Jenkins job restrictions so a runner that deploys to production never accepts jobs from untrusted repositories or branches.
- Keep long-lived secrets off the host. Use OIDC federation for cloud access, block instance metadata from jobs, and assume any secret in the runner's memory or process list is readable by the job.
Why runners are a distinct identity problem
A runner sits at the junction of three identities: the registration credential that lets it pull jobs from the CI/CD service, the operating system or Kubernetes identity the runner process runs as, and the credentials each job injects (job tokens, secrets, cloud federation tokens). A compromise of any one usually yields the others. The tj-actions/changed-files incident showed how direct this is: the tj-actions/changed-files compromise used a malicious commit that read the memory of the runner's Runner.Worker process and printed every secret it found into the workflow log. No exploit of the runner was needed, only the right to run a step.
Vendor documentation is consistent on the core risk:
- GitHub: self-hosted runners "should almost never be used for public repositories" and can be persistently compromised by untrusted workflow code.
- GitLab: a malicious job on a non-ephemeral shared runner can affect other projects, install code on the host and steal variables including
CI_JOB_TOKEN. A cloned runner reuses the same token and can pick up the original's jobs. - Jenkins: builds on the built-in node can read the controller's file system with the rights of the Jenkins process, and users with Job/Configure permission or the ability to change build scripts should be treated as less trusted than administrators.
- Azure Pipelines: an agent running under an Entra ID-backed identity can call Azure DevOps APIs directly without the job's access token, so a compromised pipeline could reach the whole organisation.
Choosing a runner model
The decision that drives everything else is whether a runner survives between jobs. The table summarises the options; the NHI Mgmt Group recommendation is single-use for anything that builds code from more than one team.
| Model | How it works | Suits | Main risk to manage |
|---|---|---|---|
| Platform-hosted | The CI/CD service provides a fresh VM per job | Public repositories, untrusted contributions, most teams | Limited network reach to internal systems; outbound data control |
| Single-use VM (JIT or autoscaled) | A VM is created for one job and destroyed after it; GitHub JIT runners via the REST API, GitLab Docker Machine or fleeting autoscaling, Azure scale set agents | Private builds needing internal network, special hardware or licensed tools | Golden image hygiene, autoscaler credentials, hardware reuse |
| Single-use pod on Kubernetes | GitHub Actions Runner Controller (ARC) scale sets or the GitLab Kubernetes executor create a pod per job | High-volume builds on an existing cluster | Shared node kernel, pod service account, Docker-in-Docker privilege |
| Persistent host | One long-running machine runs job after job | Only a single trusted repository with no external contributors | Persistence, cross-job secret theft, workspace and cache poisoning |
Decision guidance
- Public or forked contributions: use platform-hosted runners. GitHub runner groups allow only private repositories by default; leave that default in place.
- Need for internal network access: use single-use VMs in a dedicated segment rather than opening the internal network to platform-hosted runners.
- Container builds on Kubernetes: prefer a dedicated node pool for build pods so a container escape (ATT&CK T1611 Escape to Host) does not land on nodes running production workloads.
- Large estates: GitHub suggests registering runners centrally at organisation or enterprise level so one team manages them; pair that with groups that restrict each runner set to named repositories.
Registration and the runner's own credentials
Runner registration credentials are bearer secrets: whoever holds them can attach a machine that receives jobs, and with them the secrets those jobs carry.
| Platform | Registration credential | Safer practice from the vendor docs |
|---|---|---|
| GitHub Actions | Registration token or just-in-time (JIT) configuration | Create JIT runners through the REST API; each runs at most one job and is then removed. ARC requests a JIT configuration for each ephemeral runner pod and stores its GitHub API credentials in githubConfigSecret. |
| GitLab Runner | Runner authentication token (prefix glrt-), replacing legacy registration tokens | Create the runner in the UI or with POST /user/runners, register with gitlab-runner register --token. From GitLab 17.0 administrators and group owners can disable the legacy registration workflow; legacy attempts then fail with 410 Gone. |
| Jenkins | Agent secret for inbound agents, or controller-held SSH credentials for SSH agents | Keep Agent to Controller Access Control on (always enabled since Jenkins 2.326); never run builds on the built-in node. |
| Azure Pipelines | Agent registered into one agent pool | One pool per project, low-privilege agent account, project-scoped job authorisation. |
- Store registration credentials like production secrets: in a secrets manager, readable only by the provisioning pipeline, rotated when staff with access leave. See the Secrets Management Guide.
- Do not bake tokens into images: a golden image containing a GitLab authentication token is a clone waiting to happen, and GitLab notes a cloned runner picks up the original's jobs. Inject the token at boot.
- Protect the autoscaler identity: the controller that creates runner VMs or pods holds rights to create compute and often to read the registration secret. Treat it as a privileged service account and scope it to the runner project, subscription or namespace only.
The identity the runner runs as
The runner process's operating system and cloud identity is inherited by every job, so it sets the ceiling on what a malicious job can reach without stealing anything.
- Operating system account: Microsoft's guidance for Azure Pipelines agents is a non-privileged local account (
NT AUTHORITY\NETWORK SERVICE, Local Service or a gMSA on Windows; a dedicated non-root account with no sudo on Linux and macOS). The same principle applies to every runner: no root, no sudo, no membership of local administrators. - Directory accounts: avoid domain or Entra ID-backed accounts for agents, and keep build identities out of the Project Collection Service Accounts group, whose members inherit Project Collection Administrators.
- Cloud instance identity: the VM or node role attached to a runner host is reachable from any job through the instance metadata service (ATT&CK T1552.005 Cloud Instance Metadata API). Give the host role only what the runner software needs (often nothing beyond pulling images and writing logs) and let jobs obtain their own short-lived credentials through OIDC. See the Cloud Instance Metadata Services Compared Guide and the EC2 Instance Metadata Service Hardening Guide.
- Kubernetes service account: for the GitLab Kubernetes executor, set
service_accountto a dedicated account with no cluster rights, setautomount_service_account_tokento false unless a job needs the API, and keepservice_account_overwrite_allowedunset or tightly matched. ARC in kubernetes container mode creates a Role and ServiceAccount for the runner pod when none is supplied: review what it grants. See the Kubernetes Service Account Token Guide. - Job credentials: replace stored cloud keys with OIDC federation, as GitHub recommends for cloud providers and HashiCorp Vault. See the GitHub Actions OIDC Federation Hardening Guide and How to set up workload identity federation for GitLab CI to Azure.
Executor, container and host hardening
Executor choice decides how much of the host a job can touch. GitLab's runner security page is the clearest public statement of the trade-offs, and the same reasoning applies to other platforms.
| Setting or executor | Risk | What to do |
|---|---|---|
| GitLab shell executor | Jobs run as the runner user and can read other projects' code on the same host | Trusted builds only, on a dedicated host |
| Docker privileged mode, Docker-in-Docker | Disables container security mechanisms and enables breakout to the host | Only on isolated single-use VMs; in GitLab Docker Machine set MaxBuilds = 1; prefer rootless image builders |
| Docker non-privileged | Considered safe by GitLab only in this mode | Run as non-root, disable sudo, drop SETUID and SETGID with cap_drop; never share the host PID namespace |
| Kubernetes executor | Privilege escalation, broad service accounts, shared namespaces | Leave privileged off, set allow_privilege_escalation = false, use namespace_per_job or a dedicated namespace, set security contexts, restrict allowed_images and allowed_pull_policies |
Image pull policy if-not-present | On shared runners, a cached private image can be used by users without registry credentials | Use always or never on shared runners |
GIT_STRATEGY: fetch and reused workspaces | Code and submodule content carry into other users' pipelines | Use clean clones on shared runners; enable FF_ENABLE_JOB_CLEANUP on static hosts |
| Azure Pipelines container jobs | Jobs can modify agent tools and tasks | Set mountReadOnly for externals, tasks and tools; use absolute paths for security-critical binaries |
- Golden images: build runner images from a hardened baseline in their own pipeline, patch on a schedule, and remove SSH keys, cloud CLIs with cached logins and anything else a job does not need. NSA and CISA recommend keeping CI/CD assets patched, removing unneeded applications and deploying endpoint detection and response.
- Credential helpers: GitLab runs Docker credential helpers on the runner manager host; restrict them with
allowed_docker_credential_helpersand do not rundocker loginas the runner user on shared runners. - Caches: treat shared dependency and build caches as writable by any job that can use them. Separate caches by trust level so a pull request build cannot seed a cache that a release build later reads.
Scoping runners to trust levels
The NHI Mgmt Group recommendation is three tiers of runners, each with its own registration, network zone and credentials: untrusted (pull requests and forks, platform-hosted where possible), internal build (merged code, no deployment rights) and release or deploy (protected branches and tags only, holding deployment federation).
- GitHub: create runner groups under Settings, Actions, Runner groups, set Repository access to Selected repositories, and use the organisation policy to restrict which workflows can use a group. Organisation owners can also limit which repositories may create repository-level runners.
- GitLab: mark deploy runners as protected so they run jobs only on protected branches and tags, and lock project runners to their project.
- Jenkins: set the built-in node's executors to 0 under Manage Jenkins, Nodes. Labels alone cannot keep builds off a node; the Jenkins docs point to the Job Restrictions plugin to enforce which jobs run where. See the Jenkins Security Guide.
- Azure Pipelines: keep a separate agent pool per project, avoid fork builds on self-hosted agents, and turn on project-scoped job authorisation in Organisation settings, Pipelines, Settings. See the Azure DevOps Security Guide.
Network placement
- Dedicated segment: place runners in their own network zone, block inbound SSH from the internet and restrict traffic between runner VMs, as GitLab recommends. NSA and CISA call for segmentation and a demilitarised zone around CI/CD.
- Egress allowlist: runners need the CI/CD service, package registries and artefact stores. Route everything else through a proxy with an allowlist and log denied requests; the tj-actions compromise was detected through unexpected outbound traffic from runners.
- Metadata endpoints: block or filter
169.254.169.254from job containers, and on AWS require IMDSv2 with a hop limit of 1 so containers cannot reach it.
Implementing it: a phased rollout
- Inventory (weeks 1 to 2): list every runner and agent across GitHub, GitLab, Jenkins and Azure DevOps, with scope, executor, OS account, host cloud role and which repositories can reach it. Flag persistent runners serving more than one repository.
- Quick wins (weeks 2 to 4): remove self-hosted runners from public repositories, set Jenkins built-in executors to 0, drop runner accounts out of admin and sudo groups, and block metadata access from jobs.
- Tiering (month 2): create the three runner tiers with separate groups, pools or protected runners, and move deploy credentials onto the release tier only.
- Single-use (months 2 to 4): pilot JIT, autoscaled or ARC runners for one team, compare build times and failure rates with the persistent fleet, then migrate team by team. Keep old runners paused for two weeks as a rollback path, then remove them and revoke their tokens.
- Federation (months 3 to 5): replace stored cloud keys in runner jobs with OIDC, starting with deployment jobs, and shrink host roles.
- Steady state: rebuild golden images on a schedule, review runner scopes quarterly, and add runner signals to detection.
Detection and measurement
- Runner registration events: new runners, runners moved between groups and changes to group repository access in the GitHub organisation audit log; runner creation in GitLab audit events; agent pool changes in the Azure DevOps Auditing service.
- Host telemetry: EDR on runner hosts, alerting on processes reading other processes' memory, on new persistence (cron, systemd units, services) on hosts meant to be single-use, and on outbound connections outside the allowlist.
- Cloud logs: calls using the runner host role that are not runner housekeeping, and federation token exchanges from unexpected repositories or branches.
| Metric | Target (ours) |
|---|---|
| Share of self-hosted jobs run on single-use runners | Above 90 per cent |
| Runners whose process runs as root, admin or a directory account | Zero |
| Persistent runners reachable from more than one repository | Zero |
| Runner jobs using stored long-lived cloud keys | Falling each quarter, zero for deploy jobs |
| Median age of runner golden image | Under 30 days |
| Registered runners with no job in 30 days | Zero (removed and tokens revoked) |
Framework mapping
| Control area | NIST SP 800-53 Rev 5 | NIST CSF 2.0 | MITRE ATT&CK |
|---|---|---|---|
| Hardened, minimal runner images | CM-7 Least Functionality | PR.PS-01 | T1059 Command and Scripting Interpreter |
| Low-privilege runner and job identities | AC-6 Least Privilege | PR.AA-05 | T1552.005 Cloud Instance Metadata API |
| Network segmentation and egress control | SC-7 Boundary Protection | PR.PS-01 | T1611 Escape to Host |
| Secrets kept off hosts and out of logs | AC-6 Least Privilege | PR.AA-05 | T1552.001 Credentials In Files |
| Runner and host monitoring | SI-4 System Monitoring, AU-2 Event Logging | DE.CM-09 | T1195.002 Compromise Software Supply Chain |
The runner sits under OWASP Top 10 CI/CD risk CICD-SEC-7 Insecure System Configuration; see the OWASP CI/CD Top 10 CICD-SEC-7 guide.
Common failure patterns
- Persistent runner shared across many repositories: one malicious or careless job leaves a backdoor, poisons the cache or reads the next job's secrets.
- Runner process as root or a domain admin: every job inherits that identity, turning a build into a foothold on the network.
- Host role with broad cloud rights: jobs reach the metadata service and act as the host, bypassing all job-level secret controls.
- Secrets on the command line or in memory treated as private: GitHub warns that arguments are visible to other jobs through the process list, and the tj-actions/changed-files compromise read secrets straight from runner memory.
- Required reviewers mistaken for isolation: GitHub notes environments with reviewers do not isolate workflows running on a self-hosted runner.
Practitioner checklist
- Inventory every runner and agent with owner, scope, executor, OS account and host cloud role.
- Remove self-hosted runners from public repositories and fork builds.
- Move multi-repository workloads to single-use runners (JIT, autoscaled VMs, ARC or Kubernetes executor pods).
- Run the runner process as a dedicated non-root, non-admin, non-directory account.
- Create untrusted, build and release runner tiers with separate registrations, networks and credentials.
- Use GitLab authentication tokens and disable legacy registration; use GitHub JIT configuration; keep tokens out of images.
- Leave privileged containers off except on isolated single-use VMs; set Kubernetes executor security contexts.
- Block instance metadata from jobs and replace stored cloud keys with OIDC federation.
- Egress allowlist runner networks and alert on denied destinations.
- Alert on new runner registrations and group scope changes; remove idle runners monthly.
Standards and references
- GitHub Docs: Security hardening for GitHub Actions
- GitHub Docs: Managing access to self-hosted runners using groups
- GitHub Docs: About Actions Runner Controller
- GitLab Docs: Security for self-managed runners
- GitLab Docs: Migrating to the new runner registration workflow
- Jenkins: Controller Isolation
- Microsoft Learn: Secure Azure Pipelines infrastructure
- NSA and CISA: Defending Continuous Integration/Continuous Delivery (CI/CD) Environments
Related NHI Mgmt Group resources: CI/CD Security Guide · CI/CD Pipeline Identity Security Guide · GitHub and GitHub Actions Security Guide · CI/CD Pipeline Compromise Playbook