Skip to content

Securing kubectl with Cloudflare Access for SaaS

Cloudflare Access for SaaS fronts a Kubernetes API server with a self-hosted OIDC application, so kubectl authenticates against your identity provider and the cluster maps the token’s claims onto RBAC instead of a shared client certificate. This guide sets up the identity provider, the Access application and its policy, the tunnel that publishes the API server, the cloudflared deployment, and the kubelogin kubeconfig on the client.

Step 1: get an IDP set up (Google Workspace, Gmail, Entra ID, Authentik etc):

Section titled “Step 1: get an IDP set up (Google Workspace, Gmail, Entra ID, Authentik etc):”
resource "cloudflare_zero_trust_access_identity_provider" "gmail" {
account_id = var.cloudflare_account_id
name = "Gmail"
type = "google"
config {
client_id = var.google_client_id
client_secret = var.google_secret
email_claim_name = "email"
}
}
resource "cloudflare_zero_trust_access_identity_provider" "authentik_oidc" {
account_id = var.cloudflare_account_id
name = "Authentik OIDC"
type = "oidc"
config {
auth_url = "https://authentik.${var.domain_name}/application/o/authorize/"
certs_url = "https://authentik.${var.domain_name}/application/o/cloudflare-access/jwks/"
claims = ["given_name", "preferred_username", "nickname", "groups", "role"]
client_id = var.authentik_oidc_client_id
client_secret = var.authentik_oidc_secret
email_claim_name = "email"
scopes = ["openid", "email", "profile"]
token_url = "https://authentik.${var.domain_name}/application/o/token/"
}
}

Step 2: setup access application, policies, groups and outputs

Section titled “Step 2: setup access application, policies, groups and outputs”

Application for kubectl:

resource "cloudflare_zero_trust_access_application" "kubectl_saas" {
account_id = var.cloudflare_account_id
policies = [
cloudflare_zero_trust_access_policy.allow_erfi.id,
]
allowed_idps = [
cloudflare_zero_trust_access_identity_provider.gmail.id,
]
app_launcher_visible = true
auto_redirect_to_identity = false
domain = "kubectl.${var.domain_name}"
name = "kubectl"
session_duration = "24h"
type = "saas"
saas_app {
auth_type = "oidc"
redirect_uris = ["http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:18000", "http://127.0.0.1:18000", "urn:ietf:wg:oauth:2.0:oob"]
grant_types = ["authorization_code_with_pkce", "refresh_tokens"]
scopes = ["openid", "email", "profile", "groups"]
allow_pkce_without_client_secret = true
access_token_lifetime = "5m"
refresh_token_options {
lifetime = "24h"
}
}
}

Access Policy:

resource "cloudflare_zero_trust_access_policy" "allow_erfi" {
account_id = var.cloudflare_account_id
name = "Allow Erfi"
decision = "allow"
session_duration = "24h"
include {
group = [cloudflare_zero_trust_access_group.erfi_corp.id]
}
}

Access Group:

resource "cloudflare_zero_trust_access_group" "erfi_corp" {
account_id = var.cloudflare_account_id
name = "Erfi Corp"
include {
email = var.erfi_corp
}
}

Output:

output "kubectl_saas" {
value = {
client_id = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].client_id
client_secret = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].client_secret
public_key = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].public_key
auth_type = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].auth_type
redirect_uris = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].redirect_uris
grant_types = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].grant_types
scopes = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].scopes
allow_pkce_without_client_secret = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].allow_pkce_without_client_secret
access_token_lifetime = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].access_token_lifetime
refresh_token_options = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].refresh_token_options
hybrid_and_implicit_options = cloudflare_zero_trust_access_application.kubectl_saas.saas_app[0].hybrid_and_implicit_options
name = cloudflare_zero_trust_access_application.kubectl_saas.name
domain = cloudflare_zero_trust_access_application.kubectl_saas.domain
type = cloudflare_zero_trust_access_application.kubectl_saas.type
}
sensitive = true
}

Cloudflare Tunnel deployment (instead of side-car)

Section titled “Cloudflare Tunnel deployment (instead of side-car)”

DNS:

resource "cloudflare_record" "kubectl" {
zone_id = var.cloudflare_zone_id
name = "kubectl"
type = "CNAME"
content = cloudflare_zero_trust_tunnel_cloudflared.k3s.cname
proxied = true
tags = ["k3s"]
}

Tunnel Config:

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "k3s" {
account_id = var.cloudflare_account_id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.k3s.id
config {
warp_routing {
enabled = true
}
ingress_rule {
hostname = "kubectl.${var.domain_name}"
service = "https://192.168.22.9:6443"
origin_request {
origin_server_name = "https://kubernetes.default.svc.cluster.local"
http2_origin = true
no_tls_verify = true
}
}
ingress_rule {
service = "http_status:404"
}
}
}

Tunnel Secret:

resource "cloudflare_zero_trust_tunnel_cloudflared" "k3s" {
account_id = var.cloudflare_account_id
name = "k3s"
secret = base64encode(random_string.tunnel_secret.result)
config_src = "cloudflare"
}
resource "random_string" "tunnel_secret" {
length = 32
special = false
}

Tunnel k3s deployment:

resource "kubernetes_deployment" "cloudflared" {
metadata {
name = "cloudflared"
namespace = "cloudflared"
labels = {
app = "cloudflared"
}
}
spec {
replicas = 1
selector {
match_labels = {
pod = "cloudflared"
}
}
template {
metadata {
labels = {
pod = "cloudflared"
}
}
spec {
container {
image = "cloudflare/cloudflared:2025.6.1"
name = "cloudflared"
command = [
"cloudflared", "tunnel", "--no-autoupdate", "--logfile", "/etc/cloudflared/log", "--loglevel", "debug", "--metrics", "0.0.0.0:50000", "--metrics-update-freq", "5s", "--retries", "10", "run"
]
env {
name = "TUNNEL_TOKEN"
value_from {
secret_key_ref {
name = "cloudflared-credentials"
key = "token"
}
}
}
volume_mount {
mount_path = "/etc/cloudflared"
name = "log-volume"
}
liveness_probe {
http_get {
path = "/ready"
port = 50000
}
failure_threshold = 1
initial_delay_seconds = 10
period_seconds = 10
}
resources {
requests = {
cpu = "1000m"
memory = "512Mi"
}
limits = {
cpu = "2000m"
memory = "1024Mi"
}
}
}
volume {
name = "log-volume"
persistent_volume_claim {
claim_name = "cloudflared-logs"
}
}
}
}
}
}

Tunnel k8s Secret:

resource "kubernetes_secret" "cloudflared_credentials" {
metadata {
name = "cloudflared-credentials"
namespace = "cloudflared"
}
type = "Opaque"
data = {
token = cloudflare_zero_trust_tunnel_cloudflared.k3s.tunnel_token
}
}

Tunnel Service:

resource "kubernetes_service" "cloudflared_metrics" {
metadata {
name = "cloudflared-metrics"
namespace = "cloudflared"
labels = {
app = "cloudflared"
}
}
spec {
selector = {
pod = "cloudflared"
}
port {
name = "metrics"
port = 50000
target_port = 50000
protocol = "TCP"
}
type = "ClusterIP"
}
}

Storage for logs:

resource "kubernetes_persistent_volume_claim" "cloudflared_logs" {
metadata {
name = "cloudflared-logs"
namespace = "cloudflared"
}
spec {
access_modes = ["ReadWriteMany"]
resources {
requests = {
storage = "10Gi"
}
}
storage_class_name = "nfs-client"
}
}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: oidc-admin-binding
subjects:
- kind: User
name: ${EMAIL}
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
Terminal window
kubectl oidc-login setup \
--oidc-issuer-url=${GET_FROM_YOUR_TF_OUTPUT_URL} \
--oidc-client-id=${GET_FROM_YOUR_TF_OUTPUT_ID} \
--oidc-client-secret=${GET_FROM_YOUR_TF_OUTPUT_SECRET} \
--oidc-extra-scope=profile,email

This should give you a template like this:

users:
- name: default
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
args:
- oidc-login
- get-token
- --oidc-issuer-url=${GET_FROM_YOUR_TF_OUTPUT_URL}
- --oidc-client-id=${GET_FROM_YOUR_TF_OUTPUT_ID}
- --oidc-client-secret=${GET_FROM_YOUR_TF_OUTPUT_SECRET}
- --oidc-extra-scope=profile
- --oidc-extra-scope=email
- --grant-type=auto

The complete .kubeconfig should be like this:

apiVersion: v1
clusters:
- cluster:
server: https://kubectl.${YOUR_DOMAIN}
name: default
contexts:
- context:
cluster: default
user: default
name: default
- context:
cluster: default
user: oidc
name: oidc
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
args:
- oidc-login
- get-token
- --oidc-issuer-url=https://${YOUR_ACCOUNT}.cloudflareaccess.com/cdn-cgi/access/sso/oidc/${CLIENT_ID}
- --oidc-client-id=${CLIENT_ID}
- --oidc-client-secret=${CLIENT_SECRET} // not needed when using PKCE, see notes below
- --oidc-pkce-method=S256
- --oidc-extra-scope=openid
- --oidc-extra-scope=groups
- --oidc-extra-scope=profile
- --oidc-use-access-token
- --oidc-extra-scope=email
- --oidc-extra-scope=offline_access
- --grant-type=auto
- --force-refresh=false
command: kubectl
env: null
interactiveMode: IfAvailable
provideClusterInfo: false
kube-apiserver-arg:
- "oidc-issuer-url=${GET_FROM_YOUR_TF_OUTPUT_URL}
- "oidc-client-id=${GET_FROM_YOUR_TF_OUTPUT_ID}
- "oidc-username-claim=email"
- "oidc-groups-claim=groups"

Since I’m using k3s, it’s usually in /etc/rancher/k3s.

Create /etc/rancher/k3s/config.yaml

After that, just run sudo systemctl restart k3s.

And you should be good to go.

Retired topology, kept for the routing detail it documents.

Complete network architecture with VyOS, Magic WAN, and k3s

Section titled “Complete network architecture with VyOS, Magic WAN, and k3s”
Magic WAN TunnelsPolicy-Based RoutingInternal NetworksPodman Network (192.168.23.0/24)k3s Cluster (TPI Network)NodesServicesInternetCloudflare EdgeMagic WANIPsec/GREVyOS Router203.0.113.10PPPoE pppoe0kubectl.domain.com via IPsec/GREGRE Tunneltun0: 192.168.29.20/31Table 10GREIPsec Tunnelvti0: 192.168.30.20/31Table 20IPsecmagic-wan-gre-traefikTraefik -> CF via GRETable 10appliesmagic-wan-ipsec-traefikTraefik -> CF via IPsecTable 20 **ACTIVE**appliesmagic-wan-ipsec-glory-holeGlory-hole <-> CF/InternalappliesINTERNAL1eth1: 192.168.21.0/24workstation, pikvm, homehubeth1BERYLeth1.20: 192.168.24.0/24IoT deviceseth1.20TPI/k3s Networketh1.60: 192.168.22.0/24Turing Pi clustereth1.60FLINTeth2: 192.168.26.0/24Home deviceseth2PROXMOXeth3: 192.168.113.0/24VM hostseth3WireGuardwg0: 192.168.28.0/24VPN clientswg0Glory-Hole DNS192.168.23.10DoH/DoT Serverpod-podman-2Traefik Ingress192.168.22.100MetalLB LoadBalancerforwards toroutes toreturn pathroutes toDNS queriesDNS queriesDNS queriesDNS queriesMagic WAN routing table 20Pi-hole192.168.23.2upstreamUnbound192.168.23.3upstreamroutes to k3sCloudflared DNS192.168.23.5upstreamCoreDNS192.168.23.6rock1192.168.22.9Control Planerock2192.168.22.10Workerrock3192.168.22.11Workerrock4192.168.22.12WorkerPBR: cf-ipv4 -> table 20cloudflaredTunnel to CF EdgeAuthentikOIDC ProviderGrafanaPrometheushttpbun, grafana, etc outbound tunnel

Inbound (Cloudflare -> k3s):

  1. Request arrives at Cloudflare Edge
  2. Routes through Magic WAN IPsec tunnel (vti0)
  3. Enters VyOS at 192.168.30.20
  4. Firewall rule CF_IPsec accepts traffic
  5. Forwards to Traefik at 192.168.22.100:443
  6. Traefik routes to backend services

Outbound (k3s -> Cloudflare):

  1. Response from Traefik (192.168.22.100)
  2. PBR policy magic-wan-ipsec-traefik matches
  3. Rule 10: Destination = Cloudflare IPs -> table 20
  4. Routes via vti0 (IPsec tunnel)
  5. Returns to Cloudflare Edge

Key Policy Rules:

  • Table 10: GRE tunnel (tun0) - Backup/alternative path
  • Table 20: IPsec tunnel (vti0) - Active for Traefik traffic
  • Main: Default WAN via pppoe0

Firewall Zones:

  • CF_GRE: Accept all from GRE tunnel
  • CF_IPsec: Accept all from IPsec tunnel
  • TPI: Forward rules for k3s network
  • podman-2: Accept all for container network
  • kubectl -n cloudflared get pods shows the cloudflared pod Ready: its liveness probe reads /ready on port 50000, so a Ready pod is a tunnel that has registered with Cloudflare Edge.
  • The cloudflared-metrics Service exposes that same port 50000 inside the cluster, which is where the Prometheus trigger in the optional KEDA block reads cloudflared_http_requests_total from.
  • kubectl oidc-login setup prints the exec block for the kubeconfig; compare the issuer URL, client id and scopes against the complete .kubeconfig in step 6. The issuer URL carries the account’s cloudflareaccess.com host and the OIDC client id as its path.
  • The Cloudflare Access audit log for the kubectl application records the sign-in, which is the check that the policy and the identity provider are wired to the right application.
  • The ClusterRoleBinding binds the ${EMAIL} subject to cluster-admin, so the email claim in the issued token has to match that subject exactly.
  • allow_pkce_without_client_secret = true on the Access application is what lets the kubeconfig carry no secret, and the --oidc-client-secret line is commented out for that reason. Without PKCE, every kubeconfig copy holds a live secret.
  • access_token_lifetime = "5m" is short by design; the refresh token (24h) plus grant_types = ["authorization_code_with_pkce", "refresh_tokens"] is what keeps a session usable, and offline_access rides along implicitly.
  • The tunnel ingress points at https://192.168.22.9:6443 with no_tls_verify = true and http2_origin = true, and sets origin_server_name to the in-cluster name, because the API server presents its own certificate rather than one the tunnel trusts.
  • config_src = "cloudflare" keeps the tunnel configuration on Cloudflare’s side; the cloudflare_zero_trust_tunnel_cloudflared_config resource is what the deployment reads, not a local config file.
  • The k3s API server arguments live in /etc/rancher/k3s/config.yaml and only take effect after sudo systemctl restart k3s.
  • cloudflared has no load balancing across replicas and no graceful drain: down-scaling breaks the eyeball connections held by the replica being removed, so replicas are for HA only. Multiple discrete tunnels behind a load balancer give the scaling behaviour instead.
  • The liveness probe fails on the first failure (failure_threshold = 1) with a 10-second initial delay, so a slow tunnel registration restarts the pod rather than waiting.
  • The log volume is an NFS-backed ReadWriteMany PVC, which is what the /etc/cloudflared logfile mount writes to; a filesystem stall surfaces as a failed probe.
Path or objectWhat it is
/etc/rancher/k3s/config.yamlThe k3s API server arguments: oidc-issuer-url, oidc-client-id, oidc-username-claim, oidc-groups-claim
.kubeconfigThe client config, with the oidc-login get-token exec block and its scopes
cloudflare_zero_trust_access_identity_provider.gmail, .authentik_oidcThe two identity provider registrations
cloudflare_zero_trust_access_application.kubectl_saasThe SaaS application, its redirect URIs, grant types and PKCE setting
cloudflare_zero_trust_access_policy.allow_erfi, cloudflare_zero_trust_access_group.erfi_corpThe allow policy and the email group it includes
cloudflare_zero_trust_tunnel_cloudflared.k3s, cloudflare_zero_trust_tunnel_cloudflared_config.k3s, cloudflare_record.kubectlThe tunnel, its ingress rules and the kubectl CNAME
random_string.tunnel_secretThe 32-character secret encoded into the tunnel
kubernetes_deployment.cloudflared, kubernetes_secret.cloudflared_credentialsThe tunnel pod in the cloudflared namespace and the secret holding TUNNEL_TOKEN
kubernetes_service.cloudflared_metrics, kubernetes_persistent_volume_claim.cloudflared_logsThe metrics Service on port 50000 and the 10Gi NFS log volume
output.kubectl_saasThe sensitive output carrying the client id, client secret, public key and issuer details
  • Magic WAN and its interoperability - the VyOS end of the retired estate in the diagram: the GRE and IPsec tunnels, their MTU/MSS arithmetic and the routing that carried API server traffic.
  • Traefik on k3s - the ingress in the same cluster that the tunnel’s rules point at, including its own OpenTofu-managed tunnel config and DNS records.