AWS Builder Center

Three Layers of Container Security on Amazon EKS: Scan the Image, Lock the Pod, Scope the Identity

A hands-on guide to securing a Go service on Amazon EKS: build a minimal non-root image, scan it continuously with Amazon ECR and Amazon Inspector, enforce the Kubernetes "restricted" Pod Security Standard, and give pods least-privilege AWS access with EKS Pod Identity instead of access keys.

Introduction

I am doing undergraduate research on security in Kubernetes and containers. One lesson came up again and again: most container incidents are not clever zero-days. They are known CVEs in old base images, pods running as root, and credentials with far more permissions than needed.
None of these needs an expensive tool to fix. In this post I secure a small Go service on Amazon EKS using only native AWS and Kubernetes features.

The three layers

Layer
Risk
Control used here
Image
Known vulnerabilities, extra tools for attackers
Distroless non-root image + ECR enhanced scanning (Amazon Inspector)
Pod
Container escape, privilege escalation
Pod Security Standards, "restricted" level
Identity
Leaked or over-privileged AWS credentials
EKS Pod Identity with a least-privilege IAM role
Each layer assumes the previous one can fail. A vulnerable image in a locked-down pod with a narrow role does much less damage.

Step 1: Build a minimal non-root image

The smallest attack surface is the one with nothing in it. A static Go binary on a distroless base has no shell, no package manager and far fewer packages to carry CVEs.
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/order-api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
Use the numeric UID (65532 is  nonroot  in distroless). Kubernetes can verify  runAsNonRoot  only when the user is a number, not a name.
Push it to Amazon ECR:
aws ecr create-repository --repository-name order-api \
--image-tag-mutability IMMUTABLE

aws ecr get-login-password | docker login --username AWS --password-stdin \
111122223333.dkr.ecr.us-east-1.amazonaws.com

docker build -t 111122223333.dkr.ecr.us-east-1.amazonaws.com/order-api:v1 .
docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/order-api:v1
Immutable tags stop anyone from silently replacing  v1  with a different image.

Step 2: Scan images continuously with Amazon Inspector

ECR basic scanning checks an image once, at push. Enhanced scanning uses Amazon Inspector to scan OS and language packages (including Go modules) and rescans automatically when new CVEs are published.
aws inspector2 enable --resource-types ECR

aws ecr put-registry-scanning-configuration \
--scan-type ENHANCED \
--rules '[{"scanFrequency":"CONTINUOUS_SCAN",
"repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}]}]'

Block vulnerable images in CI

Add this after  docker push  in your pipeline. It waits for the scan and fails the build on any CRITICAL or HIGH finding:
REPO=order-api
TAG=v1

aws ecr wait image-scan-complete --repository-name $REPO --image-id imageTag=$TAG

COUNTS=$(aws ecr describe-image-scan-findings \
--repository-name $REPO --image-id imageTag=$TAG \
--query 'imageScanFindings.findingSeverityCounts' --output json)

echo "Findings: $COUNTS"
CRIT=$(echo "$COUNTS" | jq '(.CRITICAL // 0) + (.HIGH // 0)')

if [ "$CRIT" -gt 0 ]; then
echo "Blocking deploy: $CRIT critical/high vulnerabilities"
exit 1
fi

Get alerted about new CVEs in running images

Continuous scanning only helps if someone sees the result. An EventBridge rule can forward new critical findings to an SNS topic (email, Slack via Chatbot):
{
"source": ["aws.inspector2"],
"detail-type": ["Inspector2 Finding"],
"detail": { "severity": ["CRITICAL"] }
}

Step 3: Enforce the "restricted" Pod Security Standard

Kubernetes ships with Pod Security Admission, enabled by default on EKS. A namespace label is enough to reject pods that run as root, request privileged mode or keep dangerous Linux capabilities.
apiVersion: v1
kind: Namespace
metadata:
name: comanda
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
A Deployment that passes the restricted level:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
namespace: comanda
spec:
replicas: 2
selector:
matchLabels: { app: order-api }
template:
metadata:
labels: { app: order-api }
spec:
serviceAccountName: order-api
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: order-api
image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/order-api:v1
ports: [{ containerPort: 8080 }]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: 100m, memory: 64Mi }
limits: { memory: 128Mi }
 readOnlyRootFilesystem  is not required by the standard, but costs nothing for a Go binary that writes nothing to disk.
To adopt this in an existing cluster without breaking workloads, start with only the  warn  and  audit  labels, fix what they report, then add  enforce .

Step 4: Least-privilege AWS access with EKS Pod Identity

The worst way to give a pod AWS access is an access key in a Kubernetes Secret. The second worst is the node's instance role, because then every pod on the node shares it. EKS Pod Identity maps one Kubernetes service account to one IAM role, with short-lived credentials.
Install the agent add-on once per cluster:
aws eks create-addon --cluster-name demo --addon-name eks-pod-identity-agent
Create a role that only EKS pods can assume ( trust.json ):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "pods.eks.amazonaws.com" },
"Action": ["sts:AssumeRole", "sts:TagSession"]
}]
}
Give it only what order-api needs, here reading one secret:
aws iam create-role --role-name order-api-pod \
--assume-role-policy-document file://trust.json

aws iam put-role-policy --role-name order-api-pod --policy-name read-secret \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:comanda/order-api-*"
}]
}'
Link the role to the service account:
kubectl create serviceaccount order-api -n comanda

aws eks create-pod-identity-association \
--cluster-name demo --namespace comanda --service-account order-api \
--role-arn arn:aws:iam::111122223333:role/order-api-pod
No code change is needed: current AWS SDKs (including the Go SDK v2) find the credentials through the default chain.

Pod Identity or IRSA?


EKS Pod Identity
IRSA
Setup
Add-on + association API
OIDC provider per cluster + annotated service account
Trust policy
Same for every cluster
Contains the cluster's OIDC URL
Reuse role in many clusters
Yes, without editing it
Must add each cluster's OIDC provider
Runs on
EKS on EC2 nodes
EKS, also Fargate and self-managed clusters
For new EKS clusters on EC2 nodes, Pod Identity is simpler. IRSA is still the choice on Fargate.

Step 5: Prove it works

Security controls you never tested are assumptions. Three quick checks:
1. A privileged pod is rejected.
kubectl run bad --image=nginx --privileged -n comanda
Error from server (Forbidden): pods "bad" is forbidden:
violates PodSecurity "restricted:latest": privileged (container "bad" must not set securityContext.privileged=true), ...
2. There is no shell to abuse.
kubectl exec -it deploy/order-api -n comanda -- sh
# fails: executable file not found
3. The pod gets scoped, short-lived credentials. The Pod Identity webhook injects the credential endpoint into the container:
kubectl get pod -n comanda -l app=order-api \
-o jsonpath='{.items[0].spec.containers[0].env[*].name}'
# AWS_CONTAINER_CREDENTIALS_FULL_URI AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE ...
If the app tries anything outside its policy (for example  s3:ListBuckets ), the call returns  AccessDenied  and CloudTrail records which role tried it.

Cost

The security controls are almost free; the cluster is what costs money (approximate list prices, check the AWS pricing pages).
Item
Approx. cost
Pod Security Admission
Free (built into Kubernetes)
EKS Pod Identity
Free
Inspector ECR scanning
~USD 0.09 per image first scan, ~USD 0.01 per rescan
EKS control plane
~USD 0.10 per hour (~USD 73/month)
Accounts new to Inspector get a free trial period. For a lab, delete the cluster when you finish ( eksctl delete cluster --name demo ).

Conclusion

With a distroless image, continuous scanning, the restricted Pod Security Standard and Pod Identity, the common container attack paths get much narrower: fewer CVEs to exploit, no root or privilege escalation, and no long-lived keys to steal.
Next layers to explore:
  • Network policies to limit which pods can talk to each other.
  • Image signing with AWS Signer and a policy that admits only signed images.
  • Runtime detection with Amazon GuardDuty EKS Runtime Monitoring.
  • Policy as code with Kyverno or OPA Gatekeeper for rules beyond Pod Security Standards.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article