CKS — Minimize External Access
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
ClusterIPunless you have a specific reason to expose externally. If you must expose, preferLoadBalancerwith security groups overNodePort.
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.0makes 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
6. VPN and Private Link
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.
AWS PrivateLink for EKS
# 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?
- 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}]}}'
- Access via kubectl proxy:
kubectl proxy --port=8001
# Visit: http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/
- 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
kubectlmust 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 BalancerloadBalancerSourceRanges— 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
- Deploy a bastion host in the same VPC as the cluster
- SSH tunnel:
ssh -L 6443:k8s-api-private-ip:6443 user@bastion-public-ip - Configure kubectl:
kubectl --server=https://localhost:6443 get nodes
Approach 2: VPN (AWS Client VPN / WireGuard)
- Set up AWS Client VPN endpoint with the cluster VPC
- Connect from laptop:
# Using OpenVPN client sudo openvpn --config client-vpn-config.ovpn - Once connected, your laptop has a private IP in the VPC
- 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 |