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.
Cloudflare Access
Section titled “Cloudflare Access”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}k3s and Cloudflare Tunnel
Section titled “k3s and Cloudflare Tunnel”Step 3: Cloudflare Tunnel
Section titled “Step 3: Cloudflare Tunnel”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}Step 4: k3s deployments
Section titled “Step 4: k3s deployments”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" }}OIDC, kubelogin and RBAC setup
Section titled “OIDC, kubelogin and RBAC setup”Step 5: setup RBAC
Section titled “Step 5: setup RBAC”apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: oidc-admin-bindingsubjects:- kind: User name: ${EMAIL}roleRef: kind: ClusterRole name: cluster-admin apiGroup: rbac.authorization.k8s.ioStep 6: setup your .kubeconfig
Section titled “Step 6: setup your .kubeconfig”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,emailThis 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=autoThe complete .kubeconfig should be like this:
apiVersion: v1clusters:- cluster: server: https://kubectl.${YOUR_DOMAIN} name: defaultcontexts:- context: cluster: default user: default name: default- context: cluster: default user: oidc name: oidccurrent-context: defaultkind: Configpreferences: {}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: falseStep 7: configure k3s control node
Section titled “Step 7: configure k3s control node”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.
Architecture diagram
Section titled “Architecture diagram”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”Traffic flow details
Section titled “Traffic flow details”Inbound (Cloudflare -> k3s):
- Request arrives at Cloudflare Edge
- Routes through Magic WAN IPsec tunnel (vti0)
- Enters VyOS at 192.168.30.20
- Firewall rule CF_IPsec accepts traffic
- Forwards to Traefik at 192.168.22.100:443
- Traefik routes to backend services
Outbound (k3s -> Cloudflare):
- Response from Traefik (192.168.22.100)
- PBR policy
magic-wan-ipsec-traefikmatches - Rule 10: Destination = Cloudflare IPs -> table 20
- Routes via vti0 (IPsec tunnel)
- 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 tunnelCF_IPsec: Accept all from IPsec tunnelTPI: Forward rules for k3s networkpodman-2: Accept all for container network
Verification
Section titled “Verification”kubectl -n cloudflared get podsshows the cloudflared podReady: its liveness probe reads/readyon port 50000, so a Ready pod is a tunnel that has registered with Cloudflare Edge.- The
cloudflared-metricsService exposes that same port 50000 inside the cluster, which is where the Prometheus trigger in the optional KEDA block readscloudflared_http_requests_totalfrom. kubectl oidc-login setupprints the exec block for the kubeconfig; compare the issuer URL, client id and scopes against the complete.kubeconfigin step 6. The issuer URL carries the account’scloudflareaccess.comhost and the OIDC client id as its path.- The Cloudflare Access audit log for the
kubectlapplication 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 tocluster-admin, so the email claim in the issued token has to match that subject exactly.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”allow_pkce_without_client_secret = trueon the Access application is what lets the kubeconfig carry no secret, and the--oidc-client-secretline 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) plusgrant_types = ["authorization_code_with_pkce", "refresh_tokens"]is what keeps a session usable, andoffline_accessrides along implicitly.- The tunnel ingress points at
https://192.168.22.9:6443withno_tls_verify = trueandhttp2_origin = true, and setsorigin_server_nameto 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; thecloudflare_zero_trust_tunnel_cloudflared_configresource is what the deployment reads, not a local config file.- The k3s API server arguments live in
/etc/rancher/k3s/config.yamland only take effect aftersudo 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
ReadWriteManyPVC, which is what the/etc/cloudflaredlogfile mount writes to; a filesystem stall surfaces as a failed probe.
File reference
Section titled “File reference”| Path or object | What it is |
|---|---|
/etc/rancher/k3s/config.yaml | The k3s API server arguments: oidc-issuer-url, oidc-client-id, oidc-username-claim, oidc-groups-claim |
.kubeconfig | The client config, with the oidc-login get-token exec block and its scopes |
cloudflare_zero_trust_access_identity_provider.gmail, .authentik_oidc | The two identity provider registrations |
cloudflare_zero_trust_access_application.kubectl_saas | The SaaS application, its redirect URIs, grant types and PKCE setting |
cloudflare_zero_trust_access_policy.allow_erfi, cloudflare_zero_trust_access_group.erfi_corp | The allow policy and the email group it includes |
cloudflare_zero_trust_tunnel_cloudflared.k3s, cloudflare_zero_trust_tunnel_cloudflared_config.k3s, cloudflare_record.kubectl | The tunnel, its ingress rules and the kubectl CNAME |
random_string.tunnel_secret | The 32-character secret encoded into the tunnel |
kubernetes_deployment.cloudflared, kubernetes_secret.cloudflared_credentials | The tunnel pod in the cloudflared namespace and the secret holding TUNNEL_TOKEN |
kubernetes_service.cloudflared_metrics, kubernetes_persistent_volume_claim.cloudflared_logs | The metrics Service on port 50000 and the 10Gi NFS log volume |
output.kubectl_saas | The sensitive output carrying the client id, client secret, public key and issuer details |
Related docs
Section titled “Related docs”- 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.