CKS — Minimize External Access

9 minute read

Minimize External Access

Every externally reachable endpoint in a Kubernetes cluster is a potential attack vector. NodePort services expose ports on every node. Publicly accessible API servers invite brute-force attacks. The goal is to reduce what is reachable from outside the cluster to the absolute minimum.


1. Service Type Security Comparison

Service Type Scope Exposed On Security Risk
ClusterIP Internal only Cluster virtual IP Low — not reachable externally
NodePort External Every node on port 30000-32767 High — opens ports on all nodes
LoadBalancer External Cloud load balancer IP Medium — controllable with security groups
ExternalName DNS alias N/A (CNAME redirect) Low — but can point to external services

Default Rule: Use ClusterIP unless you have a specific reason to expose externally. If you must expose, prefer LoadBalancer with security groups over NodePort.


2. Restricting NodePort Services

Why NodePort is Dangerous

When you create a NodePort service, Kubernetes opens the assigned port on every node in the cluster — including nodes that don’t even run the target Pods.

apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  type: NodePort
  selector:
    app: my-app
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080   # Open on ALL nodes
Node 1 (runs my-app)          Node 2 (no my-app Pods)
┌─────────────────────┐       ┌─────────────────────┐
│  :30080 → my-app    │       │  :30080 → my-app    │ ← Also open!
│  (makes sense)      │       │  (why?)              │
└─────────────────────┘       └─────────────────────┘

Attacker can access my-app via ANY node IP + port 30080

Disable NodePort Services

Option 1: Restrict via Admission Policy

Use a ValidatingAdmissionPolicy (Kubernetes 1.30+) or OPA Gatekeeper to block NodePort services:

# ValidatingAdmissionPolicy to deny NodePort services
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: deny-nodeport-services
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["services"]
  validations:
  - expression: "object.spec.type != 'NodePort'"
    message: "NodePort services are not allowed. Use ClusterIP or LoadBalancer with security groups."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: deny-nodeport-services-binding
spec:
  policyName: deny-nodeport-services
  validationActions:
  - Deny
  matchResources: {}

Option 2: Restrict NodePort Range via kube-apiserver

# In kube-apiserver manifest, set a narrow or empty NodePort range
--service-node-port-range=30000-30000   # Effectively 1 port

Option 3: Firewall Rules

# Block external access to NodePort range
sudo iptables -A INPUT -p tcp --dport 30000:32767 \
  -s 0.0.0.0/0 -j DROP

# Allow only from internal network
sudo iptables -A INPUT -p tcp --dport 30000:32767 \
  -s 10.0.0.0/8 -j ACCEPT

3. Load Balancer Security

When you must expose services externally, use LoadBalancer type with cloud security groups.

AWS — Internal Load Balancer

apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    # Create an internal NLB (not internet-facing)
    service.beta.kubernetes.io/aws-load-balancer-internal: "true"
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
  type: LoadBalancer
  selector:
    app: my-app
  ports:
  - port: 443
    targetPort: 8443

AWS — Security Group Restriction

apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    # Restrict source IPs at the LB level
    service.beta.kubernetes.io/aws-load-balancer-source-ranges: "10.0.0.0/8,172.16.0.0/12"
spec:
  type: LoadBalancer
  # Also restrict via loadBalancerSourceRanges
  loadBalancerSourceRanges:
  - 10.0.0.0/8
  - 172.16.0.0/12
  selector:
    app: my-app
  ports:
  - port: 443
    targetPort: 8443

GCP — Internal Load Balancer

apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    networking.gke.io/load-balancer-type: "Internal"
spec:
  type: LoadBalancer
  selector:
    app: my-app
  ports:
  - port: 443
    targetPort: 8443

4. Internal-Only API Server Access

The Kubernetes API server is the most sensitive component. Public exposure means anyone can attempt authentication.

Private API Server Endpoints

# AWS EKS — create cluster with private endpoint only
aws eks create-cluster \
  --name my-cluster \
  --kubernetes-version 1.30 \
  --resources-vpc-config \
    endpointPublicAccess=false,endpointPrivateAccess=true

# Update existing cluster
aws eks update-cluster-config \
  --name my-cluster \
  --resources-vpc-config \
    endpointPublicAccess=false,endpointPrivateAccess=true

Restrict Public API Access by CIDR

If you must keep the public endpoint, restrict by source IP:

# Allow only your office IP
aws eks update-cluster-config \
  --name my-cluster \
  --resources-vpc-config \
    endpointPublicAccess=true,\
    endpointPrivateAccess=true,\
    publicAccessCidrs='["203.0.113.0/24"]'

Self-Managed Clusters — kube-apiserver Flags

# /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
  containers:
  - command:
    - kube-apiserver
    # Bind to private interface only
    - --advertise-address=10.0.1.10
    - --bind-address=10.0.1.10       # Not 0.0.0.0
    # Or use --secure-port on a non-standard port
    - --secure-port=6443

Security Impact: Binding the API server to 0.0.0.0 makes it accessible from every network interface on the node. Binding to a private IP restricts access to the internal network.

API Server Access Comparison

Configuration Internet Accessible Internal Accessible Risk Level
Public + no CIDR restriction Yes (anyone) Yes Critical
Public + CIDR restriction Yes (allowed IPs) Yes Medium
Private only No Yes (VPC) Low
Private + VPN No Yes (VPN users) Lowest

5. Bastion Host Patterns

A bastion host (jump box) is the single entry point into a private network.

Internet          DMZ                 Private Network
                  ┌──────────┐
                  │ Bastion  │
User ────SSH────→ │ Host     │ ──SSH──→  K8s Nodes
                  │ (hardened)│ ──kubectl→ API Server
                  └──────────┘
                  
Only port 22 open      Bastion can reach internal resources
to the internet        Cluster has no public exposure

Bastion Host Hardening

# SSH config on the bastion
PermitRootLogin no
PasswordAuthentication no
AllowUsers bastion-user
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2

# Minimal software — only SSH and kubectl
# No Docker, no compilers, no curl

SSH Tunneling Through Bastion

# Tunnel kubectl through the bastion
ssh -L 6443:api-server-private-ip:6443 bastion-user@bastion-public-ip

# In another terminal, kubectl connects via localhost:6443
kubectl --server=https://localhost:6443 get nodes

SSH ProxyJump (Preferred)

# ~/.ssh/config
Host bastion
  HostName bastion.example.com
  User bastion-user
  IdentityFile ~/.ssh/bastion-key

Host k8s-node-*
  ProxyJump bastion
  User admin
  IdentityFile ~/.ssh/node-key

# Connect directly through bastion
ssh k8s-node-01

VPN Access to Cluster

                     ┌─────────────────────────────┐
User ──VPN Tunnel──→ │  VPC / Private Network      │
                     │  ┌───────────┐              │
                     │  │ API Server│ :6443        │
                     │  └───────────┘              │
                     │  ┌─────┐ ┌─────┐ ┌─────┐   │
                     │  │Node1│ │Node2│ │Node3│   │
                     │  └─────┘ └─────┘ └─────┘   │
                     └─────────────────────────────┘
                     
No public endpoints. All access through encrypted VPN tunnel.
# Enable PrivateLink for the EKS API server
# (Automatically done when endpointPrivateAccess=true)

# From your VPC, resolve the API server endpoint
# to a private IP (not public)
nslookup ABCDEF1234567890.gr7.us-east-1.eks.amazonaws.com
# Should return 10.0.x.x (private IP)

Service Mesh as Internal Access Control

# Istio AuthorizationPolicy — restrict access to internal services
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: allow-internal-only
  namespace: app-ns
spec:
  action: ALLOW
  rules:
  - from:
    - source:
        namespaces: ["app-ns", "monitoring"]
  # Implicitly deny all other sources

7. Kubernetes Dashboard Security

The Kubernetes Dashboard is a common target. If exposed publicly, attackers can manage the entire cluster.

Secure Dashboard Deployment

# Never expose the Dashboard via NodePort or LoadBalancer
# Use kubectl proxy instead
kubectl proxy --port=8001

# Access via localhost only
# http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/

Dashboard RBAC

# Read-only ServiceAccount for the Dashboard
apiVersion: v1
kind: ServiceAccount
metadata:
  name: dashboard-viewer
  namespace: kubernetes-dashboard
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: dashboard-viewer-binding
subjects:
- kind: ServiceAccount
  name: dashboard-viewer
  namespace: kubernetes-dashboard
roleRef:
  kind: ClusterRole
  name: view         # Built-in read-only role
  apiGroup: rbac.authorization.k8s.io

Exam Tip: On the CKS exam, if you see the Kubernetes Dashboard exposed via NodePort, that’s a finding. Change it to ClusterIP and access via kubectl proxy.


8. Ingress Security

When you need HTTP(S) access, use an Ingress controller with TLS — not raw NodePort.

Ingress with TLS Termination

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app-ingress
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - app.example.com
    secretName: app-tls-secret
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-app
            port:
              number: 80

Rate Limiting and IP Whitelisting

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app-ingress
  annotations:
    # Rate limiting
    nginx.ingress.kubernetes.io/limit-rps: "10"
    nginx.ingress.kubernetes.io/limit-connections: "5"
    # IP whitelisting
    nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.0/24,10.0.0.0/8"
spec:
  ingressClassName: nginx
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-app
            port:
              number: 80

Summary

Control Implementation What It Prevents
Avoid NodePort Use ClusterIP + Ingress Ports open on all nodes
LB security groups loadBalancerSourceRanges Unauthorized LB access
Private API server endpointPublicAccess=false Internet-facing API server
Bastion host SSH jump box, ProxyJump Direct node SSH from internet
VPN / PrivateLink Encrypted tunnel to VPC All public exposure
Dashboard security ClusterIP + kubectl proxy Dashboard as attack vector
Ingress TLS TLS termination, IP whitelist Unencrypted HTTP, open access

Practice Questions

Q1: A cluster has a Kubernetes Dashboard exposed as a NodePort service on port 30443. How do you fix this security issue?
  1. Change the Service type to ClusterIP:
kubectl -n kubernetes-dashboard edit svc kubernetes-dashboard

Change type: NodePort to type: ClusterIP and remove the nodePort field.

Or patch it:

kubectl -n kubernetes-dashboard patch svc kubernetes-dashboard \
  -p '{"spec": {"type": "ClusterIP", "ports": [{"port": 443, "targetPort": 8443}]}}'
  1. Access via kubectl proxy:
kubectl proxy --port=8001
# Visit: http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/
  1. Verify NodePort is gone:
ss -tlnp | grep 30443
# Should show nothing
Q2: The EKS API server is publicly accessible without CIDR restrictions. The security team requires it to be private-only. What changes do you make?
# Disable public endpoint, enable private endpoint
aws eks update-cluster-config \
  --name my-cluster \
  --resources-vpc-config \
    endpointPublicAccess=false,endpointPrivateAccess=true

After this change:

  • The API server is only accessible from within the VPC
  • kubectl must be run from a bastion host, VPN-connected machine, or resource inside the VPC
  • DNS resolution of the API server endpoint returns a private IP

Set up access:

# Option 1: SSH tunnel through bastion
ssh -L 6443:API_SERVER_PRIVATE_IP:6443 user@bastion

# Option 2: VPN connected to the VPC
# kubectl works directly from VPN-connected machine
Q3: Write a Service definition that creates an internal (not internet-facing) AWS Network Load Balancer, restricted to traffic from 10.0.0.0/8 only.
apiVersion: v1
kind: Service
metadata:
  name: internal-app
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-internal: "true"
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
  type: LoadBalancer
  loadBalancerSourceRanges:
  - 10.0.0.0/8
  selector:
    app: internal-app
  ports:
  - port: 443
    targetPort: 8443
    protocol: TCP

Key annotations:

  • aws-load-balancer-internal: "true" — creates an internal LB (no public IP)
  • aws-load-balancer-type: "nlb" — uses Network Load Balancer
  • loadBalancerSourceRanges — restricts source IPs at the Kubernetes level
Q4: You need to access a private EKS cluster from your laptop. The cluster has no public API endpoint. Describe two approaches.

Approach 1: Bastion Host / Jump Box

  1. Deploy a bastion host in the same VPC as the cluster
  2. SSH tunnel:
    ssh -L 6443:k8s-api-private-ip:6443 user@bastion-public-ip
    
  3. Configure kubectl:
    kubectl --server=https://localhost:6443 get nodes
    

Approach 2: VPN (AWS Client VPN / WireGuard)

  1. Set up AWS Client VPN endpoint with the cluster VPC
  2. Connect from laptop:
    # Using OpenVPN client
    sudo openvpn --config client-vpn-config.ovpn
    
  3. Once connected, your laptop has a private IP in the VPC
  4. kubectl connects directly to the API server’s private endpoint

Approach 2 is preferred for teams — it scales to multiple users without sharing bastion SSH keys.

Q5: Write a ValidatingAdmissionPolicy that prevents the creation of NodePort services in any namespace.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: deny-nodeport
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["services"]
  validations:
  - expression: "object.spec.type != 'NodePort'"
    message: "NodePort services are prohibited. Use ClusterIP or LoadBalancer."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: deny-nodeport-binding
spec:
  policyName: deny-nodeport
  validationActions:
  - Deny
  matchResources: {}

Test:

# This should be rejected
kubectl create service nodeport test-svc --tcp=80:8080
# Error: NodePort services are prohibited. Use ClusterIP or LoadBalancer.

# This should be allowed
kubectl create service clusterip test-svc --tcp=80:8080
# service/test-svc created

Previous Up Next
IAM Roles System Hardening AppArmor

Updated: