Join our Newsletter — 33% off our NHI Course
Home› Guides› Self-hosted CI/CD Runners and Build Agents: Isolating Jobs…
Guide Software & AI Supply Chain

Self-hosted CI/CD Runners and Build Agents: Isolating Jobs and the Credentials They Hold

← All guides
By NHI Mgmt Group, edited by Lalit Choda Updated 11 November 2026 15 min read
On this page

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.

ModelHow it worksSuitsMain risk to manage
Platform-hostedThe CI/CD service provides a fresh VM per jobPublic repositories, untrusted contributions, most teamsLimited 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 agentsPrivate builds needing internal network, special hardware or licensed toolsGolden image hygiene, autoscaler credentials, hardware reuse
Single-use pod on KubernetesGitHub Actions Runner Controller (ARC) scale sets or the GitLab Kubernetes executor create a pod per jobHigh-volume builds on an existing clusterShared node kernel, pod service account, Docker-in-Docker privilege
Persistent hostOne long-running machine runs job after jobOnly a single trusted repository with no external contributorsPersistence, 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.

PlatformRegistration credentialSafer practice from the vendor docs
GitHub ActionsRegistration token or just-in-time (JIT) configurationCreate 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 RunnerRunner authentication token (prefix glrt-), replacing legacy registration tokensCreate 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.
JenkinsAgent secret for inbound agents, or controller-held SSH credentials for SSH agentsKeep Agent to Controller Access Control on (always enabled since Jenkins 2.326); never run builds on the built-in node.
Azure PipelinesAgent registered into one agent poolOne 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_account to a dedicated account with no cluster rights, set automount_service_account_token to false unless a job needs the API, and keep service_account_overwrite_allowed unset 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 executorRiskWhat to do
GitLab shell executorJobs run as the runner user and can read other projects' code on the same hostTrusted builds only, on a dedicated host
Docker privileged mode, Docker-in-DockerDisables container security mechanisms and enables breakout to the hostOnly on isolated single-use VMs; in GitLab Docker Machine set MaxBuilds = 1; prefer rootless image builders
Docker non-privilegedConsidered safe by GitLab only in this modeRun as non-root, disable sudo, drop SETUID and SETGID with cap_drop; never share the host PID namespace
Kubernetes executorPrivilege escalation, broad service accounts, shared namespacesLeave 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-presentOn shared runners, a cached private image can be used by users without registry credentialsUse always or never on shared runners
GIT_STRATEGY: fetch and reused workspacesCode and submodule content carry into other users' pipelinesUse clean clones on shared runners; enable FF_ENABLE_JOB_CLEANUP on static hosts
Azure Pipelines container jobsJobs can modify agent tools and tasksSet 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_helpers and do not run docker login as 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.254 from job containers, and on AWS require IMDSv2 with a hop limit of 1 so containers cannot reach it.

Implementing it: a phased rollout

  1. 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.
  2. 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.
  3. Tiering (month 2): create the three runner tiers with separate groups, pools or protected runners, and move deploy credentials onto the release tier only.
  4. 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.
  5. Federation (months 3 to 5): replace stored cloud keys in runner jobs with OIDC, starting with deployment jobs, and shrink host roles.
  6. 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.
MetricTarget (ours)
Share of self-hosted jobs run on single-use runnersAbove 90 per cent
Runners whose process runs as root, admin or a directory accountZero
Persistent runners reachable from more than one repositoryZero
Runner jobs using stored long-lived cloud keysFalling each quarter, zero for deploy jobs
Median age of runner golden imageUnder 30 days
Registered runners with no job in 30 daysZero (removed and tokens revoked)

Framework mapping

Control areaNIST SP 800-53 Rev 5NIST CSF 2.0MITRE ATT&CK
Hardened, minimal runner imagesCM-7 Least FunctionalityPR.PS-01T1059 Command and Scripting Interpreter
Low-privilege runner and job identitiesAC-6 Least PrivilegePR.AA-05T1552.005 Cloud Instance Metadata API
Network segmentation and egress controlSC-7 Boundary ProtectionPR.PS-01T1611 Escape to Host
Secrets kept off hosts and out of logsAC-6 Least PrivilegePR.AA-05T1552.001 Credentials In Files
Runner and host monitoringSI-4 System Monitoring, AU-2 Event LoggingDE.CM-09T1195.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

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

Free weekly newsletter

Subscribe to the NHI & AI Identity Journal

The latest on NHI and Agentic AI security – articles, research, breaches, news and events every week.

Bonus 33% off our NHI Course when you subscribe.

NHIMG Editorial Note
Researched and written by the NHI Mgmt Group editorial team using AI tools, under the editorial direction of Lalit Choda. Facts are drawn from the sources listed on this page and checked by our automated quality process. Last updated 11 November 2026. Spotted an error? Use our contact page.
NHI Mgmt Group, the independent authority on Non-Human Identity, IAM and Agentic AI security. nhimg.org