Cloud-Native & DevOps · Sep 2026 · 20 min read
Building an EKS Security Baseline: RBAC, Pod Security Standards, and NetworkPolicy, Verified
A hub-spoke Transit Gateway network in Terraform, plus RBAC, Pod Security Standards, and NetworkPolicy applied to a real Kubernetes cluster — including a real NetworkPolicy bug caught only because the "allowed" connection failed for a different reason than the blocked one.
Four Controls, Verified Independently
"Enterprise EKS security" usually gets described as a checklist: enable RBAC, set Pod Security Standards, add NetworkPolicies, use Transit Gateway for hub-spoke networking. Every item on that list is easy to state and surprisingly easy to get subtly wrong in a way that looks correct until you actually test it. This article applies all four to a real cluster and a real Terraform network, and reports two genuine bugs caught in the process — one in the NetworkPolicy design itself, one in the test environment's own default networking.
No live EKS cluster was provisioned for the Kubernetes-layer testing in this article — a full multi-AZ EKS cluster costs real money to run for the hours this took to get right. Instead, the exact same Kubernetes API objects (RBAC, Pod Security Standards, NetworkPolicy) were applied to a real kind cluster with Calico as the CNI, which enforces the same Kubernetes-native APIs EKS does. The Terraform networking layer (VPC, Transit Gateway) was validated with terraform validate against the AWS provider but not applied, since that also costs real money to stand up for a demonstration. Both scopes are stated plainly in each section rather than implied to be more than they are.
Before Step 1, four terms this walkthrough leans on:
- Pod Security Standards (PSS) — Kubernetes' built-in, label-based replacement for the deprecated PodSecurityPolicy. Applying
pod-security.kubernetes.io/enforce: restrictedto a namespace makes the API server itself reject any pod that doesn't meet a strict security baseline — no admission webhook or third-party tool required. - RBAC least privilege — granting a ServiceAccount exactly the verbs on exactly the resources it needs, and nothing else, verified with
kubectl auth can-i --as=rather than assumed from reading the YAML. - NetworkPolicy — a Kubernetes-native firewall between pods. Critically, a
policyTypes: [Ingress, Egress]policy with an emptypodSelectorapplies to every pod in the namespace — including ones you only meant to restrict from one direction, which is exactly the bug this article hits. - Hub-spoke Transit Gateway topology — one central "hub" VPC for shared services, one or more "spoke" VPCs (here, the one running EKS) connected through AWS Transit Gateway rather than a full mesh of VPC peering connections, which stops scaling cleanly past a handful of VPCs.
Step 1 — The network layer: hub-spoke VPCs and Transit Gateway in Terraform
resource "aws_vpc" "hub" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
}
resource "aws_vpc" "eks" {
cidr_block = "10.1.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
}
resource "aws_ec2_transit_gateway" "main" {
description = "TGW connecting hub VPC and EKS spoke VPC"
default_route_table_association = "enable"
default_route_table_propagation = "enable"
}
resource "aws_ec2_transit_gateway_vpc_attachment" "hub" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
vpc_id = aws_vpc.hub.id
subnet_ids = [aws_subnet.hub_private.id]
}
resource "aws_ec2_transit_gateway_vpc_attachment" "eks" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
vpc_id = aws_vpc.eks.id
subnet_ids = [aws_subnet.eks_private_a.id, aws_subnet.eks_private_b.id]
}
Why the two VPCs use 10.0.0.0/16 and 10.1.0.0/16: Transit Gateway routes between attached VPCs based on their CIDR ranges — overlapping ranges would make routing ambiguous the same way it would for VPC peering. Distinct, non-overlapping ranges are a hard prerequisite, not a style preference.
Why the EKS subnets carry kubernetes.io/cluster/<name>: shared and kubernetes.io/role/internal-elb: 1 tags: these aren't cosmetic — the AWS Load Balancer Controller and the Kubernetes cloud-provider integration both discover which subnets to place internal load balancers in by reading these exact tags. An EKS cluster with untagged subnets will provision, but its Services of type LoadBalancer will fail to find a subnet automatically.
terraform init -backend=false
terraform validate
# Success! The configuration is valid.
Step 2 — RBAC: a CI ServiceAccount with exactly the permissions it needs
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: payments
name: payments-deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: payments-deployer-binding
namespace: payments
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: payments
roleRef:
kind: Role
name: payments-deployer
apiGroup: rbac.authorization.k8s.io
Why this is a Role, not a ClusterRole: a Role is namespace-scoped by definition — this ServiceAccount can only ever act within payments, regardless of what its bindings say, because the permission simply doesn't exist outside that namespace at the API level. That's a stronger guarantee than "we configured it to only touch this namespace."
Verified, not assumed — three real kubectl auth can-i checks against the live API server, impersonating the ServiceAccount:
kubectl auth can-i create deployments --as=system:serviceaccount:payments:ci-deployer -n payments
# yes
kubectl auth can-i delete namespaces --as=system:serviceaccount:payments:ci-deployer -n payments
# no
kubectl auth can-i get secrets --as=system:serviceaccount:payments:ci-deployer -n payments
# no
A CI pipeline using this ServiceAccount can deploy the application it's responsible for and nothing else — it can't read secrets it wasn't explicitly granted, and it can't touch the namespace itself.
Step 3 — Pod Security Standards: a real rejection, with the exact reasons
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
Deliberately deploying a privileged pod into this namespace:
kubectl run insecure-test --image=nginx -n payments \
--overrides='{"spec":{"containers":[{"name":"insecure-test","image":"nginx","securityContext":{"privileged":true}}]}}'
Error from server (Forbidden): pods "insecure-test" is forbidden: violates PodSecurity "restricted:latest":
privileged (container "insecure-test" must not set securityContext.privileged=true),
allowPrivilegeEscalation != false (container "insecure-test" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "insecure-test" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "insecure-test" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "insecure-test" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
Five separate violations, rejected before the pod ever scheduled — this is enforced by the API server itself, not a policy engine that runs after the fact and reports a violation later.
A real bug this same step caught: the first attempt at a properly-secured pod used a stock nginx image with runAsNonRoot: true. It scheduled successfully, then immediately crashed:
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)
Passing Pod Security Standards and actually running are two different things — stock nginx writes to root-owned directories on startup regardless of what securityContext you attach, so restricted mode requires either an nginx image built to run non-root (several exist specifically for this) or a different base image entirely. This is exactly the kind of gap between "the policy allowed it" and "it works" that only shows up by actually running the pod.
Step 4 — NetworkPolicy: the bug that only showed up as a different failure
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-db
namespace: payments
spec:
podSelector:
matchLabels:
app: payments-db
ingress:
- from:
- podSelector:
matchLabels:
app: payments-api
ports:
- protocol: TCP
port: 5432
policyTypes:
- Ingress
The intent: deny everything by default, then explicitly allow payments-api to reach payments-db on port 5432. Testing with three real pods and nc:
kubectl exec payments-api -n payments -- nc -zv -w3 $DB_IP 5432
# nc: connection timed out
That's wrong — this connection was supposed to be allowed. The bug: default-deny-all's policyTypes: [Ingress, Egress] applies to every pod in the namespace, including payments-api itself. The allow-api-to-db policy only grants ingress to the database — nothing grants payments-api permission to send egress traffic anywhere at all. Blocking by default and only opening one direction of one policy is a genuinely common way to write a NetworkPolicy that looks complete and isn't.
The fix — a matching egress rule on the source pod:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-api-to-db
namespace: payments
spec:
podSelector:
matchLabels:
app: payments-api
egress:
- to:
- podSelector:
matchLabels:
app: payments-db
ports:
- protocol: TCP
port: 5432
policyTypes:
- Egress
kubectl exec payments-api -n payments -- nc -zv -w3 $DB_IP 5432
# 10.244.171.198 (10.244.171.198:5432) open
kubectl exec unauthorized-pod -n payments -- nc -zv -w3 $DB_IP 5432
# nc: connection timed out
Both results now correct: the intended connection works, the unauthorized one is blocked — verified as two independently different, correct outcomes, not just "no errors."
Step 5 — The second bug: the test environment's own CNI
Before the fix above could even be properly tested, a second issue surfaced. On the cluster's default CNI (kindnet), both the allowed and blocked connections timed out identically — which isn't what a real bug in the policy looks like (that would show one direction failing, not both failing the same way regardless of policy content). Removing every NetworkPolicy entirely and retesting confirmed it: the connection succeeded instantly with no NetworkPolicy applied at all, meaning the default CNI was enforcing something inconsistently rather than correctly evaluating the policies.
The fix was installing Calico as the cluster's actual CNI, recreating the cluster with disableDefaultCNI: true so only one CNI was ever active:
kind create cluster --name eks-security-lab --config kind-config.yaml
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/calico.yaml
kubectl wait --for=condition=Ready pods -l k8s-app=calico-node -n kube-system --timeout=120s
Why this matters far beyond a local test cluster: this is not just a kind-specific quirk — it's the same class of gap AWS's own default EKS networking had for years. The default AWS VPC CNI didn't enforce Kubernetes NetworkPolicy at all until AWS shipped a dedicated aws-network-policy-agent add-on. A NetworkPolicy manifest applying cleanly with kubectl apply and showing no errors has never been proof it's actually being enforced — on real EKS, that means explicitly confirming the VPC CNI's network policy enforcement is enabled, not assuming it from the manifest alone.
Closing Thoughts
Every control in this article — RBAC, Pod Security Standards, NetworkPolicy, hub-spoke Terraform networking — is describable in a single sentence and looks complete on paper. Two real bugs surfaced anyway: a NetworkPolicy that blocked its own intended traffic because default-deny-all's egress scope was wider than it looked, and a CNI that silently didn't enforce policy correctly at all. Neither would have been visible from reading the YAML. The only way either was caught was running the actual traffic test and treating "both directions failed the same way" as a signal that something upstream of the policy itself was wrong, rather than assuming the policy was simply misconfigured.
GitHub Repository: eks-security-lab — the Terraform network module, both versions of the NetworkPolicy (broken and fixed), and every command used to verify each control.
Reviewed against Kubernetes 1.32, Calico 3.29.1, and the AWS Terraform provider (~> 5.0) as of September 2026.
AWS · EKS · Kubernetes Security · RBAC · Pod Security Standards · NetworkPolicy · Terraform