How to grant CI/CD pipelines access private Kubernetes clusters without static credentials

•

TL;DR: A base64'd kubeconfig in a repo secret is a standing liability. OIDC federation fixes identity but not the network path to a private API endpoint. Twingate Privileged Access for Kubernetes closes both gaps.

In March 2026, a campaign that researchers at CloudSEK dubbed TeamPCP walked off with 78,330 secrets from the CI/CD pipelines of 2,186 organizations. Seven months later, on October 21, attackers slipped malicious code into a LiteLLM release pipeline that was engineered specifically to hunt Kubernetes service account tokens and scrape secrets out of GitLab and Jenkins runners.

Both incidents made the same point from different angles. If your path to a private Kubernetes cluster depends on a long-lived credential sitting in a pipeline, you are one supply chain compromise away from an attacker having the same access as your deploy job.

This piece is about why the usual fixes don't fully close the gap, and what a working alternative looks like.

The two problems with CI/CD access to Kubernetes

Pipelines need to reach the Kubernetes API. That is non-negotiable. The question is how, and if teams are being honest, the answer for most of them is "a kubeconfig stashed in a secret store, generated once, rotated when someone remembers."

There are two problems baked into that pattern, and they get solved on different timelines by different tools.

Problem one: identity. The runner authenticates to the cluster using a static credential. If that credential leaks, whoever has it is you. There is no second factor, no behavioral check, no short expiry unless you built one.

Problem two: network path. The runner needs to reach the API server. If the cluster has a private endpoint (as it should), you need some way to bridge the runner's network to the cluster's network.

Most teams solve problem one and problem two with different patches, and the patches don't always talk to each other.

OIDC federation fixes identity, not reachability

The current best practice for identity is OIDC federation. GitHub Actions can assume an AWS IAM role via the GitHub OIDC provider, and that role maps to an EKS access entry. GKE has Workload Identity Federation for GitHub Actions. GitLab has similar patterns for both.

This is a real improvement. The runner no longer stores a cluster credential, and instead it presents a short-lived token minted from its workflow identity, exchanges it for cloud credentials, and those credentials map to Kubernetes RBAC. If the runner is compromised mid-job, the attacker gets a token good for the length of that job and no longer.

But OIDC federation only handles the authentication question. It says nothing about how the runner's traffic gets to a private API endpoint.

In practice, teams fill that gap with one of four approaches:

  1. Self-hosted runners inside the VPC. Works, but now you own runner infrastructure, autoscaling, patching, and the blast radius of a runner compromise is "inside your production network."

  2. AWS SSM Session Manager or an IAP tunnel. Requires cloud-specific tooling, a bastion host, and careful IAM. Logs live wherever the cloud provider decides.

  3. A jumphost with an SSH tunnel. The classic. Fine until it isn't. SSH keys become the new long-lived credential.

  4. Make the API endpoint public with authorized networks. The quiet one. Nobody writes a blog post about it, but it happens. CIDR allowlists for GitHub Actions runners are either enormous or inaccurate, and the endpoint is now on the internet.

Each of these splits the problem into two tools with two audit trails, two failure modes, and two things to maintain.

What a cleaner model looks like

The goal is a runner that:

  • Holds no cluster credential

  • Reaches a private API endpoint without the endpoint being public

  • Authenticates with an identity you can attribute, revoke, and audit

  • Writes every action to a log you control

Twingate Privileged Access for Kubernetes was built for this shape of problem. Here is how it maps to the runner use case.

The pieces

A headless Twingate Client runs on the CI runner. Headless means no GUI, no user login, authenticated with a Service Key. It behaves like any other Client: it intercepts traffic to Resources defined in your Twingate tenant and tunnels that traffic to a Connector in your environment.

A Gateway sits in your own infrastructure, in front of the cluster. The Twingate Gateway is a Layer 7 proxy that understands the Kubernetes API. Unlike a plain TCP tunnel, it can inspect the request, map it to a Twingate Service Account identity, and pass that identity through to the cluster so native RBAC applies.

The cluster API server stays private. No public endpoint, and no authorized networks dance. The Connector reaches it from inside your network, and the Connector only makes outbound connections to Twingate without any inbound firewall rules.

How the runner gets a kubeconfig

Instead of storing a kubeconfig in a secret, the runner generates one on demand. The Twingate CLI has a kube config sync command that writes contexts to the local kubeconfig based on the Kubernetes Resources the runner is authorized to reach.

Sync kubeconfig contexts for authorized clusters:

Now kubectl works against any cluster context the runner's Service Account is allowed to see. The runner never saw a cluster credential. There is nothing in the pipeline's secret store that grants direct access to Kubernetes.

A full pipeline step looks roughly like this:

GitHub Actions job that deploys to a private cluster:


The kubectl apply traverses: runner → Twingate Client → Connector → Gateway → API server. The Gateway attaches the Service Account identity to the request. The cluster enforces RBAC against that identity.

What the audit trail looks like

Every Kubernetes API call through the Gateway gets logged to stdout on the Gateway container, in your environment. You pipe those logs to whatever you already use — Loki, Datadog, Splunk, CloudWatch. The logs include the Twingate identity, the verb, the resource, the namespace, and the response.

This matters for two reasons. First, "who deployed this" stops being "the shared cicd-deploy service account" and becomes a specific Twingate Service Account tied to a specific pipeline. Second, the logs live where you can keep them, not in a vendor's 30-day retention bucket behind a paywall.

The honest tradeoff

The runner still holds one secret: a Twingate Service Key. If an attacker owns the runner, they can use that key.

What they can't do with it:

  • Reach the Kubernetes API directly. The key authenticates to Twingate, not to your cluster. The Gateway still enforces RBAC against the Service Account identity Twingate asserts.

  • Keep the access after you revoke it. Service Keys are revocable from the admin console. Revocation is immediate.

  • Hide. Every API call through the Gateway is logged with an identity you can attribute to a specific key.

Compare that to a leaked kubeconfig with a long-lived token, or a leaked SSH key to a bastion, and the shape of the problem changes. You go from "an attacker has standing access to production" to "an attacker has a revocable key that leaves fingerprints on everything it touches."

That is not perfection, but it is a smaller blast radius and a shorter window.

Where this fits versus the usual patterns

A quick comparison of how the common approaches handle the two problems:

Approach

Identity

Network path

Audit trail

Operational cost

Static kubeconfig in secret

Long-lived token

Public endpoint or in-VPC runner

API server audit log only

Low upfront, high incident cost

OIDC federation + public endpoint

Short-lived, federated

Public endpoint with CIDR allowlist

Cloud + API server

Medium; CIDR lists drift

OIDC federation + in-VPC runners

Short-lived, federated

Internal

Cloud + API server

High; you run runners

OIDC + SSM/bastion tunnel

Short-lived, federated

Tunneled

Split across cloud + SSH + API server

Medium; two systems to maintain

Twingate Privileged Access

Service Account via Gateway

Private, outbound-only Connector

Unified Gateway log in your environment

Low; one agent, one Gateway

The Twingate pattern isn't the only one that works. It's the one that collapses the identity and network problems into a single control with a single audit trail, which is usually the thing that breaks down first when an incident happens and someone asks "what did that runner do?"

Starting point

Twingate includes up to 5 privileged Resources on any plan, including the free Starter tier. That is enough to put one or two production clusters behind the Gateway and run a real pipeline against them before you commit.

The thing to measure after you set it up: how many secrets in your pipeline's secret store still grant direct access to production. The goal is one.

Closing

For the full setup walkthrough, including Gateway deployment and Service Account configuration, see the Twingate Privileged Access for Kubernetes documentation and the headless Client documentation.

New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.

Rapidly implement a modern Zero Trust network that is more secure and maintainable than VPNs.

/

CI/CD to K8s

How to grant CI/CD pipelines access private Kubernetes clusters without static credentials

•

TL;DR: A base64'd kubeconfig in a repo secret is a standing liability. OIDC federation fixes identity but not the network path to a private API endpoint. Twingate Privileged Access for Kubernetes closes both gaps.

In March 2026, a campaign that researchers at CloudSEK dubbed TeamPCP walked off with 78,330 secrets from the CI/CD pipelines of 2,186 organizations. Seven months later, on October 21, attackers slipped malicious code into a LiteLLM release pipeline that was engineered specifically to hunt Kubernetes service account tokens and scrape secrets out of GitLab and Jenkins runners.

Both incidents made the same point from different angles. If your path to a private Kubernetes cluster depends on a long-lived credential sitting in a pipeline, you are one supply chain compromise away from an attacker having the same access as your deploy job.

This piece is about why the usual fixes don't fully close the gap, and what a working alternative looks like.

The two problems with CI/CD access to Kubernetes

Pipelines need to reach the Kubernetes API. That is non-negotiable. The question is how, and if teams are being honest, the answer for most of them is "a kubeconfig stashed in a secret store, generated once, rotated when someone remembers."

There are two problems baked into that pattern, and they get solved on different timelines by different tools.

Problem one: identity. The runner authenticates to the cluster using a static credential. If that credential leaks, whoever has it is you. There is no second factor, no behavioral check, no short expiry unless you built one.

Problem two: network path. The runner needs to reach the API server. If the cluster has a private endpoint (as it should), you need some way to bridge the runner's network to the cluster's network.

Most teams solve problem one and problem two with different patches, and the patches don't always talk to each other.

OIDC federation fixes identity, not reachability

The current best practice for identity is OIDC federation. GitHub Actions can assume an AWS IAM role via the GitHub OIDC provider, and that role maps to an EKS access entry. GKE has Workload Identity Federation for GitHub Actions. GitLab has similar patterns for both.

This is a real improvement. The runner no longer stores a cluster credential, and instead it presents a short-lived token minted from its workflow identity, exchanges it for cloud credentials, and those credentials map to Kubernetes RBAC. If the runner is compromised mid-job, the attacker gets a token good for the length of that job and no longer.

But OIDC federation only handles the authentication question. It says nothing about how the runner's traffic gets to a private API endpoint.

In practice, teams fill that gap with one of four approaches:

  1. Self-hosted runners inside the VPC. Works, but now you own runner infrastructure, autoscaling, patching, and the blast radius of a runner compromise is "inside your production network."

  2. AWS SSM Session Manager or an IAP tunnel. Requires cloud-specific tooling, a bastion host, and careful IAM. Logs live wherever the cloud provider decides.

  3. A jumphost with an SSH tunnel. The classic. Fine until it isn't. SSH keys become the new long-lived credential.

  4. Make the API endpoint public with authorized networks. The quiet one. Nobody writes a blog post about it, but it happens. CIDR allowlists for GitHub Actions runners are either enormous or inaccurate, and the endpoint is now on the internet.

Each of these splits the problem into two tools with two audit trails, two failure modes, and two things to maintain.

What a cleaner model looks like

The goal is a runner that:

  • Holds no cluster credential

  • Reaches a private API endpoint without the endpoint being public

  • Authenticates with an identity you can attribute, revoke, and audit

  • Writes every action to a log you control

Twingate Privileged Access for Kubernetes was built for this shape of problem. Here is how it maps to the runner use case.

The pieces

A headless Twingate Client runs on the CI runner. Headless means no GUI, no user login, authenticated with a Service Key. It behaves like any other Client: it intercepts traffic to Resources defined in your Twingate tenant and tunnels that traffic to a Connector in your environment.

A Gateway sits in your own infrastructure, in front of the cluster. The Twingate Gateway is a Layer 7 proxy that understands the Kubernetes API. Unlike a plain TCP tunnel, it can inspect the request, map it to a Twingate Service Account identity, and pass that identity through to the cluster so native RBAC applies.

The cluster API server stays private. No public endpoint, and no authorized networks dance. The Connector reaches it from inside your network, and the Connector only makes outbound connections to Twingate without any inbound firewall rules.

How the runner gets a kubeconfig

Instead of storing a kubeconfig in a secret, the runner generates one on demand. The Twingate CLI has a kube config sync command that writes contexts to the local kubeconfig based on the Kubernetes Resources the runner is authorized to reach.

Sync kubeconfig contexts for authorized clusters:

Now kubectl works against any cluster context the runner's Service Account is allowed to see. The runner never saw a cluster credential. There is nothing in the pipeline's secret store that grants direct access to Kubernetes.

A full pipeline step looks roughly like this:

GitHub Actions job that deploys to a private cluster:


The kubectl apply traverses: runner → Twingate Client → Connector → Gateway → API server. The Gateway attaches the Service Account identity to the request. The cluster enforces RBAC against that identity.

What the audit trail looks like

Every Kubernetes API call through the Gateway gets logged to stdout on the Gateway container, in your environment. You pipe those logs to whatever you already use — Loki, Datadog, Splunk, CloudWatch. The logs include the Twingate identity, the verb, the resource, the namespace, and the response.

This matters for two reasons. First, "who deployed this" stops being "the shared cicd-deploy service account" and becomes a specific Twingate Service Account tied to a specific pipeline. Second, the logs live where you can keep them, not in a vendor's 30-day retention bucket behind a paywall.

The honest tradeoff

The runner still holds one secret: a Twingate Service Key. If an attacker owns the runner, they can use that key.

What they can't do with it:

  • Reach the Kubernetes API directly. The key authenticates to Twingate, not to your cluster. The Gateway still enforces RBAC against the Service Account identity Twingate asserts.

  • Keep the access after you revoke it. Service Keys are revocable from the admin console. Revocation is immediate.

  • Hide. Every API call through the Gateway is logged with an identity you can attribute to a specific key.

Compare that to a leaked kubeconfig with a long-lived token, or a leaked SSH key to a bastion, and the shape of the problem changes. You go from "an attacker has standing access to production" to "an attacker has a revocable key that leaves fingerprints on everything it touches."

That is not perfection, but it is a smaller blast radius and a shorter window.

Where this fits versus the usual patterns

A quick comparison of how the common approaches handle the two problems:

Approach

Identity

Network path

Audit trail

Operational cost

Static kubeconfig in secret

Long-lived token

Public endpoint or in-VPC runner

API server audit log only

Low upfront, high incident cost

OIDC federation + public endpoint

Short-lived, federated

Public endpoint with CIDR allowlist

Cloud + API server

Medium; CIDR lists drift

OIDC federation + in-VPC runners

Short-lived, federated

Internal

Cloud + API server

High; you run runners

OIDC + SSM/bastion tunnel

Short-lived, federated

Tunneled

Split across cloud + SSH + API server

Medium; two systems to maintain

Twingate Privileged Access

Service Account via Gateway

Private, outbound-only Connector

Unified Gateway log in your environment

Low; one agent, one Gateway

The Twingate pattern isn't the only one that works. It's the one that collapses the identity and network problems into a single control with a single audit trail, which is usually the thing that breaks down first when an incident happens and someone asks "what did that runner do?"

Starting point

Twingate includes up to 5 privileged Resources on any plan, including the free Starter tier. That is enough to put one or two production clusters behind the Gateway and run a real pipeline against them before you commit.

The thing to measure after you set it up: how many secrets in your pipeline's secret store still grant direct access to production. The goal is one.

Closing

For the full setup walkthrough, including Gateway deployment and Service Account configuration, see the Twingate Privileged Access for Kubernetes documentation and the headless Client documentation.

New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.

Rapidly implement a modern Zero Trust network that is more secure and maintainable than VPNs.

How to grant CI/CD pipelines access private Kubernetes clusters without static credentials

•

TL;DR: A base64'd kubeconfig in a repo secret is a standing liability. OIDC federation fixes identity but not the network path to a private API endpoint. Twingate Privileged Access for Kubernetes closes both gaps.

In March 2026, a campaign that researchers at CloudSEK dubbed TeamPCP walked off with 78,330 secrets from the CI/CD pipelines of 2,186 organizations. Seven months later, on October 21, attackers slipped malicious code into a LiteLLM release pipeline that was engineered specifically to hunt Kubernetes service account tokens and scrape secrets out of GitLab and Jenkins runners.

Both incidents made the same point from different angles. If your path to a private Kubernetes cluster depends on a long-lived credential sitting in a pipeline, you are one supply chain compromise away from an attacker having the same access as your deploy job.

This piece is about why the usual fixes don't fully close the gap, and what a working alternative looks like.

The two problems with CI/CD access to Kubernetes

Pipelines need to reach the Kubernetes API. That is non-negotiable. The question is how, and if teams are being honest, the answer for most of them is "a kubeconfig stashed in a secret store, generated once, rotated when someone remembers."

There are two problems baked into that pattern, and they get solved on different timelines by different tools.

Problem one: identity. The runner authenticates to the cluster using a static credential. If that credential leaks, whoever has it is you. There is no second factor, no behavioral check, no short expiry unless you built one.

Problem two: network path. The runner needs to reach the API server. If the cluster has a private endpoint (as it should), you need some way to bridge the runner's network to the cluster's network.

Most teams solve problem one and problem two with different patches, and the patches don't always talk to each other.

OIDC federation fixes identity, not reachability

The current best practice for identity is OIDC federation. GitHub Actions can assume an AWS IAM role via the GitHub OIDC provider, and that role maps to an EKS access entry. GKE has Workload Identity Federation for GitHub Actions. GitLab has similar patterns for both.

This is a real improvement. The runner no longer stores a cluster credential, and instead it presents a short-lived token minted from its workflow identity, exchanges it for cloud credentials, and those credentials map to Kubernetes RBAC. If the runner is compromised mid-job, the attacker gets a token good for the length of that job and no longer.

But OIDC federation only handles the authentication question. It says nothing about how the runner's traffic gets to a private API endpoint.

In practice, teams fill that gap with one of four approaches:

  1. Self-hosted runners inside the VPC. Works, but now you own runner infrastructure, autoscaling, patching, and the blast radius of a runner compromise is "inside your production network."

  2. AWS SSM Session Manager or an IAP tunnel. Requires cloud-specific tooling, a bastion host, and careful IAM. Logs live wherever the cloud provider decides.

  3. A jumphost with an SSH tunnel. The classic. Fine until it isn't. SSH keys become the new long-lived credential.

  4. Make the API endpoint public with authorized networks. The quiet one. Nobody writes a blog post about it, but it happens. CIDR allowlists for GitHub Actions runners are either enormous or inaccurate, and the endpoint is now on the internet.

Each of these splits the problem into two tools with two audit trails, two failure modes, and two things to maintain.

What a cleaner model looks like

The goal is a runner that:

  • Holds no cluster credential

  • Reaches a private API endpoint without the endpoint being public

  • Authenticates with an identity you can attribute, revoke, and audit

  • Writes every action to a log you control

Twingate Privileged Access for Kubernetes was built for this shape of problem. Here is how it maps to the runner use case.

The pieces

A headless Twingate Client runs on the CI runner. Headless means no GUI, no user login, authenticated with a Service Key. It behaves like any other Client: it intercepts traffic to Resources defined in your Twingate tenant and tunnels that traffic to a Connector in your environment.

A Gateway sits in your own infrastructure, in front of the cluster. The Twingate Gateway is a Layer 7 proxy that understands the Kubernetes API. Unlike a plain TCP tunnel, it can inspect the request, map it to a Twingate Service Account identity, and pass that identity through to the cluster so native RBAC applies.

The cluster API server stays private. No public endpoint, and no authorized networks dance. The Connector reaches it from inside your network, and the Connector only makes outbound connections to Twingate without any inbound firewall rules.

How the runner gets a kubeconfig

Instead of storing a kubeconfig in a secret, the runner generates one on demand. The Twingate CLI has a kube config sync command that writes contexts to the local kubeconfig based on the Kubernetes Resources the runner is authorized to reach.

Sync kubeconfig contexts for authorized clusters:

Now kubectl works against any cluster context the runner's Service Account is allowed to see. The runner never saw a cluster credential. There is nothing in the pipeline's secret store that grants direct access to Kubernetes.

A full pipeline step looks roughly like this:

GitHub Actions job that deploys to a private cluster:


The kubectl apply traverses: runner → Twingate Client → Connector → Gateway → API server. The Gateway attaches the Service Account identity to the request. The cluster enforces RBAC against that identity.

What the audit trail looks like

Every Kubernetes API call through the Gateway gets logged to stdout on the Gateway container, in your environment. You pipe those logs to whatever you already use — Loki, Datadog, Splunk, CloudWatch. The logs include the Twingate identity, the verb, the resource, the namespace, and the response.

This matters for two reasons. First, "who deployed this" stops being "the shared cicd-deploy service account" and becomes a specific Twingate Service Account tied to a specific pipeline. Second, the logs live where you can keep them, not in a vendor's 30-day retention bucket behind a paywall.

The honest tradeoff

The runner still holds one secret: a Twingate Service Key. If an attacker owns the runner, they can use that key.

What they can't do with it:

  • Reach the Kubernetes API directly. The key authenticates to Twingate, not to your cluster. The Gateway still enforces RBAC against the Service Account identity Twingate asserts.

  • Keep the access after you revoke it. Service Keys are revocable from the admin console. Revocation is immediate.

  • Hide. Every API call through the Gateway is logged with an identity you can attribute to a specific key.

Compare that to a leaked kubeconfig with a long-lived token, or a leaked SSH key to a bastion, and the shape of the problem changes. You go from "an attacker has standing access to production" to "an attacker has a revocable key that leaves fingerprints on everything it touches."

That is not perfection, but it is a smaller blast radius and a shorter window.

Where this fits versus the usual patterns

A quick comparison of how the common approaches handle the two problems:

Approach

Identity

Network path

Audit trail

Operational cost

Static kubeconfig in secret

Long-lived token

Public endpoint or in-VPC runner

API server audit log only

Low upfront, high incident cost

OIDC federation + public endpoint

Short-lived, federated

Public endpoint with CIDR allowlist

Cloud + API server

Medium; CIDR lists drift

OIDC federation + in-VPC runners

Short-lived, federated

Internal

Cloud + API server

High; you run runners

OIDC + SSM/bastion tunnel

Short-lived, federated

Tunneled

Split across cloud + SSH + API server

Medium; two systems to maintain

Twingate Privileged Access

Service Account via Gateway

Private, outbound-only Connector

Unified Gateway log in your environment

Low; one agent, one Gateway

The Twingate pattern isn't the only one that works. It's the one that collapses the identity and network problems into a single control with a single audit trail, which is usually the thing that breaks down first when an incident happens and someone asks "what did that runner do?"

Starting point

Twingate includes up to 5 privileged Resources on any plan, including the free Starter tier. That is enough to put one or two production clusters behind the Gateway and run a real pipeline against them before you commit.

The thing to measure after you set it up: how many secrets in your pipeline's secret store still grant direct access to production. The goal is one.

Closing

For the full setup walkthrough, including Gateway deployment and Service Account configuration, see the Twingate Privileged Access for Kubernetes documentation and the headless Client documentation.

New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.