A freshly created EKS cluster works, but it's permissive. Every pod can reach every other pod, workloads can run as root, and the easy way to give an application AWS access is to attach permissions to the node role, which hands them to every pod on that node.
This post works through four controls that close those gaps, using a single example workload: an orders-api deployment in the orders namespace that needs a database password from AWS Secrets Manager.
- IRSA — per-workload IAM roles instead of node-wide permissions.
- NetworkPolicies — default-deny traffic, then allow only what's needed.
- Pod Security Admission (restricted) — block privileged and root containers at admission time.
- Secrets Store CSI driver — mount Secrets Manager secrets as files, rather than copying them into Kubernetes Secrets by hand.
The examples assume eksctl, kubectl, helm and the AWS CLI, and a cluster called prod-eu in eu-west-2.
1. IAM Roles for Service Accounts (IRSA)
IRSA lets a Kubernetes service account assume an IAM role. The pod receives a projected, short-lived token, and the AWS SDK exchanges it for temporary credentials. No access keys, and no permissions on the node.
First, register the cluster's OIDC issuer with IAM (once per cluster):
eksctl utils associate-iam-oidc-provider --cluster prod-eu --region eu-west-2 --approve
Next, a least-privilege policy. This one can read exactly one secret:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
"Resource": "arn:aws:secretsmanager:eu-west-2:123456789012:secret:prod/orders/db-??????"
}
]
}
The -?????? suffix matters: Secrets Manager appends six random characters to every secret ARN. If the secret is encrypted with a customer-managed KMS key, the role also needs kms:Decrypt on that key.
Then create the role and service account together:
aws iam create-policy --policy-name orders-api-secrets \
--policy-document file://orders-api-secrets.json
eksctl create iamserviceaccount \
--cluster prod-eu --region eu-west-2 \
--namespace orders --name orders-api \
--role-name orders-api-irsa \
--attach-policy-arn arn:aws:iam::123456789012:policy/orders-api-secrets \
--approve
eksctl creates a role whose trust policy only accepts tokens for that specific service account:
"Condition": {
"StringEquals": {
"oidc.eks.eu-west-2.amazonaws.com/id/EXAMPLE0123456789:sub": "system:serviceaccount:orders:orders-api",
"oidc.eks.eu-west-2.amazonaws.com/id/EXAMPLE0123456789:aud": "sts.amazonaws.com"
}
}
and annotates the service account with eks.amazonaws.com/role-arn. If you manage the service account in Git rather than letting eksctl own it, add that annotation yourself.
Two follow-ups make IRSA meaningful:
- Strip the node role back to what the kubelet and CNI need. Anything extra there is available to every pod.
-
Block pods from the instance metadata service so they can't fall back to node credentials. Set the IMDSv2 hop limit to 1 on your launch template (or
disablePodIMDS: truein aneksctlnode group).
EKS Pod Identity is the newer alternative and avoids per-cluster OIDC setup and trust-policy editing. IRSA still works everywhere, including self-managed tooling that predates Pod Identity, which is why it's shown here; the rest of this post works the same with either.
2. NetworkPolicies
Kubernetes allows all pod-to-pod traffic by default. NetworkPolicies change that, but only if something enforces them. The Amazon VPC CNI can do this natively; enable it on the add-on:
aws eks update-addon --cluster-name prod-eu --region eu-west-2 \
--addon-name vpc-cni \
--configuration-values '{"enableNetworkPolicy": "true"}'
Start with default-deny for the namespace, in both directions:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: orders
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]
Everything in orders is now isolated, including DNS, which is the usual first surprise. Allow it back:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: orders
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
Then open the specific paths orders-api needs: inbound from the ingress controller, and outbound to the database subnet.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-api
namespace: orders
spec:
podSelector:
matchLabels:
app: orders-api
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- { protocol: TCP, port: 8080 }
egress:
- to:
- ipBlock:
cidr: 10.0.64.0/20 # private database subnets
ports:
- { protocol: TCP, port: 5432 }
The trade-off is operational: every new dependency now needs a policy change, and a missing rule shows up as a timeout rather than a clear error. Roll out per namespace, and keep policies next to the application's manifests so they change together.
3. Pod Security Admission: restricted
Pod Security Admission is built into Kubernetes and is configured with namespace labels. The restricted profile rejects pods that run as root, allow privilege escalation, keep Linux capabilities, or skip a seccomp profile.
Turn on warn and audit first to see what would break, then enforce:
kubectl label namespace orders \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted \
--overwrite
A deployment that passes looks like this. The securityContext blocks are the part that matters:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: orders
spec:
replicas: 2
selector:
matchLabels: { app: orders-api }
template:
metadata:
labels: { app: orders-api }
spec:
serviceAccountName: orders-api
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: 123456789012.dkr.ecr.eu-west-2.amazonaws.com/orders-api:1.4.2
ports: [{ containerPort: 8080 }]
env:
- name: SPRING_CONFIG_IMPORT
value: "optional:configtree:/mnt/secrets/"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: db-secrets, mountPath: /mnt/secrets, readOnly: true }
volumes:
- name: tmp
emptyDir: {}
- name: db-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: orders-db
readOnlyRootFilesystem isn't required by restricted, but it's cheap. A Spring Boot app with embedded Tomcat needs a writable /tmp, hence the emptyDir.
Leave kube-system and other system namespaces at privileged or baseline; CNI, CSI and logging agents legitimately need host access.
4. Secrets Manager via the Secrets Store CSI driver
The CSI driver mounts secrets into the pod at start-up, fetched using the pod's own IAM identity, which is the IRSA role from step 1. Install the driver and the AWS provider:
helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
helm repo add aws-secrets-manager https://aws.github.io/secrets-store-csi-driver-provider-aws
helm install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver \
--namespace kube-system
helm install secrets-provider-aws aws-secrets-manager/secrets-store-csi-driver-provider-aws \
--namespace kube-system
Then describe which secret to mount. The secret prod/orders/db holds JSON with username and password keys; jmesPath splits them into separate files:
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: orders-db
namespace: orders
spec:
provider: aws
parameters:
objects: |
- objectName: "prod/orders/db"
objectType: "secretsmanager"
jmesPath:
- path: username
objectAlias: spring.datasource.username
- path: password
objectAlias: spring.datasource.password
The aliases are deliberate. With SPRING_CONFIG_IMPORT=optional:configtree:/mnt/secrets/, Spring Boot treats each file name as a property name and its contents as the value, so the datasource is configured with no code and no environment variables holding secrets.
Trade-offs to know about:
-
Rotation isn't automatic end to end. The driver can refresh mounted files (
enableSecretRotation=trueon the Helm chart), but Spring reads config trees at start-up. You'll need a rolling restart after rotation, or a connection pool that re-reads credentials. -
Syncing to Kubernetes Secrets (
secretObjects, withsyncSecret.enabled=true) is available if you need environment variables, but it puts the value back in etcd. If you do, make sure envelope encryption with KMS is enabled on the cluster. - Pods won't start if the secret can't be fetched. That's usually what you want, but it makes Secrets Manager and STS part of your start-up path.
Summary
None of these controls is exotic, and together they remove the most common EKS footguns:
- IRSA (or Pod Identity) gives each workload its own narrowly scoped IAM role; strip the node role and block IMDS so pods can't bypass it.
- Default-deny NetworkPolicies, with explicit allows for DNS and real dependencies.
-
restrictedPod Security on application namespaces, rolled out viawarnandauditfirst. - Secrets mounted from Secrets Manager using the workload's own identity, with a clear plan for rotation.
Top comments (0)