Managing access to Kubernetes clusters across multiple environments can quickly become complex. In the past, I had to distribute kubeconfigs and manage client certificates for every engineer. I wanted to make secure access to Kubernetes clusters more straightforward.

💡
TLDR: You can use TailscaleKubeProxy to access the Kubernetes API with the Tailscale identity.

Headscale & Tailscale

In my company, every employee has access to the corporate network via Tailscale. We use Headscale as a self-hosted control plane. Within the corporate network, every user is identified by their Tailscale IP.

Idea: Map Tailscale Identity → Kubernetes User

Kubernetes supports a feature called User Impersonation. It allows a trusted component to make API requests on behalf of another user by setting specific HTTP headers.

In particular, the following headers are relevant:

  • Impersonate-User
  • Impersonate-Uid

When these headers are set, the Kubernetes API server evaluates the request using the impersonated identity instead of the original authenticated user. Authorization is then enforced via RBAC as usual.

This feature is typically used by API gateways or aggregation layers. In this project, it serves as the bridge between Tailscale identity and Kubernetes RBAC.

Implementation

The solution consists of a small reverse proxy that runs inside the Kubernetes cluster. It exposes the Kubernetes API through a Tailscale-enabled endpoint and performs identity translation.

The proxy has three responsibilities:

  1. Accept incoming connections from the Tailscale network
  2. Resolve the Tailscale identity of the caller
  3. Forward the request to the Kubernetes API using impersonation headers

The proxy itself authenticates to the Kubernetes API using a ServiceAccount token.

Creating a Tailscale-enabled HTTP Server

Instead of running a separate tailscaled process, the proxy uses the tsnet package. This allows embedding a Tailscale node directly into the Go application.

s := &tsnet.Server{
    Hostname: "tailscale-kube-proxy",
}
defer s.Close()

lc, err := s.LocalClient()
if err != nil {
    log.Fatal(err)
}

Extracting the Tailscale Identity

When a request arrives, we use WhoIs() to determine which Tailscale user is associated with the source IP address.

who, err := lc.WhoIs(r.Context(), r.RemoteAddr)

This call resolves the Tailscale node and provides access to the authenticated user's profile information, including the login name.

Injecting Kubernetes Impersonation Headers

The reverse proxy modifies outgoing requests before forwarding them to the Kubernetes API.

proxy.Director = func(r *http.Request) {
    originalDirector(r)

    // Clear any existing impersonation headers to prevent header injection
    r.Header.Del("Impersonate-User")
    r.Header.Del("Impersonate-Group")

    // Identify the Tailscale user making the request based on their IP
    who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
    if err == nil {
        // Set Kubernetes impersonation headers to enable RBAC based on Tailscale identity
        r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
        r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
    }
}

Before setting impersonation headers, existing headers are removed to prevent header injection attacks.

The proxy authenticates itself to Kubernetes using a ServiceAccount token while impersonating the Tailscale user.

Required RBAC Permissions

For impersonation to work, the ServiceAccount used by the proxy must be allowed to impersonate users:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: tailscale-kube-proxy-impersonator
rules:
- apiGroups: [""]
  resources: ["users", "groups", "serviceaccounts"]
  verbs: ["impersonate"]

Usage / Deployment

The tailscale-kube-proxy project is available on GitHub. Once the proxy is built or the image pulled, here’s how to deploy and use it.

1. Create a ServiceAccount and RBAC Permissions

The proxy requires permission to impersonate users in Kubernetes and to manage its own secrets. For this, create a ServiceAccount, ClusterRole, and ClusterRoleBinding:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tailscale-kube-proxy
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: tailscale-kube-proxy-impersonator
rules:
- apiGroups: [""]
  resources: ["users", "groups", "serviceaccounts"]
  verbs: ["impersonate"]
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tailscale-kube-proxy-impersonator
subjects:
- kind: ServiceAccount
  name: tailscale-kube-proxy
  namespace: kube-system
roleRef:
  kind: ClusterRole
  name: tailscale-kube-proxy-impersonator
  apiGroup: rbac.authorization.k8s.io

Note: This ClusterRole allows access to all secrets. Currently, this is acceptable because the proxy only manages its own secret, but improvements are possible to further restrict access.

2. Create a Tailscale Auth Key

To join the Tailscale network, the proxy needs a pre-authentication key. With Headscale, you can generate one for a user:

headscale preauthkeys create --user <USER_ID>

Store the auth key in a Kubernetes secret:

apiVersion: v1
kind: Secret
metadata:
  name: tailscale-kube-proxy-secret
  namespace: kube-system
type: Opaque
stringData:
  authKey: "PUT_YOUR_AUTH_KEY_HERE"

This secret allows the proxy to authenticate to the Tailscale network and persist necessary network data.

3. Deploy the Proxy

Finally, deploy the proxy as a single pod in your cluster:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: tailscale-kube-proxy
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: tailscale-kube-proxy
  template:
    metadata:
      labels:
        app: tailscale-kube-proxy
    spec:
      serviceAccountName: tailscale-kube-proxy
      containers:
      - name: proxy
        image: ghcr.io/0x2321/tailscale-kube-proxy:latest
        env:
        - name: SECRET_NAME
          value: "tailscale-kube-proxy-secret"
        - name: HOSTNAME
          value: "awesome-cluster"

4. Accessing the Cluster

To use the proxy, simply connect to the Tailscale network and point kubectl to the proxy endpoint:

export KUBERNETES_SERVER=https://tailscale-proxy
kubectl --server=$KUBERNETES_SERVER get pods

The proxy automatically determines your Tailscale identity and enforces the correct permissions in Kubernetes.

Kubernetes API access over Tailscale

TailscaleKubeProxy lets you securely access Kubernetes using your Tailscale identity — no kubeconfigs or certificates needed.