Created on 2026-06-22
The Premise: Network Location Is Not an Identity
In traditional "castle-and-moat" architectures, being inside a VPC or private subnet granted baseline trust. Requests from 10.0.0.0/8 were implicitly "safe." A compromised EC2 instance in the same subnet as an RDS database could query it freely because network adjacency was the authorization model.
Zero Trust inverts this completely:
Network location provides zero authorization. Every request-external, internal, same-node, same-pod-must be explicitly authenticated, authorized, and encrypted.
This is not a product you install. It's an architectural property that emerges from the disciplined combination of identity-centric policies, cryptographic attestation, microsegmented networking, layered data gates, and continuous verification. On AWS, implementing it requires working across five interlocking pillars.
Pillar 1: Identity as the Primary Perimeter
AWS IAM is the root policy engine. In a Zero Trust model, the enforcement boundary shifts from IP CIDRs to identity tokens and cryptographic attestation. The corollary: if it has long-lived credentials, it's a trust anchor you haven't eliminated yet.
Eliminating Long-Lived Credentials
Every static credential-IAM user access key, hardcoded database password, SSH private key checked into a repo-is a lateral movement vector. The Zero Trust mandate is absolute: no long-lived credentials anywhere in the system.
| Workload Type | Anti-Pattern | Zero Trust Pattern |
|---|---|---|
| EC2 instances | Access keys in ~/.aws/credentials |
Instance profiles with IAM roles |
| ECS tasks | Shared task execution role | Per-task IAM roles via taskRoleArn |
| EKS pods | Node-level instance profile | EKS Pod Identity or IRSA (projected service account tokens) |
| CI/CD pipelines | Static "deployer" IAM user keys | OIDC federation directly to IAM roles |
| Human operators | IAM users with console passwords | IAM Identity Center (SSO) + external IdP + FIDO2/WebAuthn |
| Cross-account access | Shared credentials in Secrets Manager | sts:AssumeRole with external ID and session policies |
OIDC Federation for CI/CD
This eliminates the single most common credential leak vector in modern AWS deployments-the CI/CD deployer key:
# Terraform: GitHub Actions OIDC provider
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_deploy" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Pin to specific repo AND branch - not org-wide
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}]
})
}
The sub claim pinning is critical. Without it, any repository in the GitHub organization can assume the role-a common misconfiguration that defeats the purpose entirely.
Context-Aware Authorization (ABAC)
Static IAM group membership (Developers, Admins) is a coarse authorization model. Attribute-Based Access Control (ABAC) evaluates request context dynamically.
Consider a fintech company where data engineers, ML engineers, and analysts all need S3 access-but to different buckets with different sensitivity levels. Instead of maintaining separate policies per team, one ABAC policy handles it:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "ABACTeamAndClassificationMatch",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::acme-datalake-*/*",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/team": "${s3:ResourceTag/team}",
"aws:PrincipalTag/data-classification": "${s3:ResourceTag/data-classification}",
"aws:PrincipalTag/cost-center": "${s3:ResourceTag/cost-center}"
},
"StringEqualsIfExists": {
"aws:PrincipalTag/compliance-scope": "${s3:ResourceTag/compliance-scope}"
},
"Bool": {
"aws:MultiFactorAuthPresent": "true"
}
}
}]
}
Tag your IAM roles and S3 objects like this:
| Principal (IAM Role) | team |
data-classification |
cost-center |
compliance-scope |
|---|---|---|---|---|
role/ml-training-pipeline |
ml-platform |
confidential |
cc-4200 |
soc2 |
role/analytics-dashboard |
analytics |
internal |
cc-3100 |
- |
role/fraud-detection-svc |
ml-platform |
restricted |
cc-4200 |
pci-dss |
| S3 Object Prefix | team |
data-classification |
cost-center |
compliance-scope |
|---|---|---|---|---|
acme-datalake-features/ |
ml-platform |
confidential |
cc-4200 |
soc2 |
acme-datalake-reports/ |
analytics |
internal |
cc-3100 |
- |
acme-datalake-txn-raw/ |
ml-platform |
restricted |
cc-4200 |
pci-dss |
The fraud-detection-svc role (tagged team=ml-platform, data-classification=restricted, compliance-scope=pci-dss) can access acme-datalake-txn-raw/ but not acme-datalake-reports/-wrong team, wrong classification, wrong cost center. The analytics-dashboard role cannot touch PCI-scoped data even if someone accidentally grants s3:GetObject on *, because the tag conditions will fail.
This single policy replaces potentially dozens of static resource-level policies. No MFA in the session? Denied regardless of tag match.
Session Policies: Runtime Permission Scoping
When assuming a role via STS, you can attach a session policy that further constrains the role's permissions for that specific session:
sts = boto3.client('sts')
response = sts.assume_role(
RoleArn='arn:aws:iam::123456789012:role/ml-training-pipeline',
RoleSessionName='feature-extraction-daily-2026-09-22',
Policy=json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-datalake-txn-raw/2026/09/22/*",
"Condition": {
"StringEquals": {
"s3:ResourceTag/data-classification": "restricted",
"s3:ResourceTag/compliance-scope": "pci-dss"
}
}
}]
}),
Tags=[
{"Key": "triggered-by", "Value": "airflow-dag-feature-extract"},
{"Key": "run-id", "Value": "run-2026-09-22-0400"},
],
DurationSeconds=900 # 15 minutes - just enough for the ETL window
)
The resulting session can only read today's PCI-scoped transaction partition, even though the underlying ml-training-pipeline role has broader S3 access. The session tags (triggered-by, run-id) flow into CloudTrail, giving auditors a direct link from data access back to the specific Airflow DAG run that triggered it. This is least privilege applied at the session level-each job gets exactly the permissions it needs for exactly the duration it needs them.
Pillar 2: Network Disintermediation & Microsegmentation
Zero Trust does not mean "networks don't matter." It means the network is hostile by default. Network controls act as guardrails and blast-radius containment, not trust anchors.
Eliminating Bastions and Open Ingress Ports
Every open inbound port is an attack surface. SSH (22) and RDP (3389) have been the entry point for countless breaches. AWS Systems Manager Session Manager replaces them entirely:
# Security group: ZERO inbound rules
resource "aws_security_group" "workload" {
name_prefix = "zero-trust-workload-"
vpc_id = var.vpc_id
# No ingress rules at all
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # HTTPS to AWS APIs only
description = "SSM, CloudWatch, S3 (gateway endpoint preferred)"
}
}
# SSM access is authenticated via IAM, logged to CloudTrail
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.workload.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
Traffic tunnels over TLS through the AWS API plane. Authentication is IAM. Every session is logged to CloudTrail and optionally streamed to S3/CloudWatch. There is no SSH key to rotate, no bastion to patch, no port to scan.
Port forwarding through SSM handles the "but I need to reach the database" objection:
# Forward local port 5432 to RDS through SSM - no inbound SG rules needed
aws ssm start-session \
--target i-0abc123def456 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["mydb.cluster-xyz.us-east-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["5432"]}'
VPC Lattice: Layer 7 Service Networking
VPC Lattice replaces the complexity of Transit Gateway routing tables, VPC peering, and perimeter firewalls with service-to-service Layer 7 proxying that enforces IAM authorization per HTTP request:
┌─────────────────────────────────────────────────────────┐
│ VPC Lattice │
│ │
│ ┌──────────┐ SigV4 Auth ┌──────────┐ │
│ │ Service A ├────────────────►│ Service B │ │
│ │ (VPC-1) │ IAM Policy │ (VPC-2) │ │
│ └──────────┘ Per-Request └──────────┘ │
│ │
│ Auth Policy: │
│ ─ Principal matches role/service-a-prod │
│ ─ HTTP method is GET or POST │
│ ─ Path matches /api/v2/* │
│ ─ Header X-Request-Source equals "internal" │
└─────────────────────────────────────────────────────────┘
This is fundamentally different from network-level controls. A Security Group can allow TCP/443 between two CIDR ranges. VPC Lattice can allow GET /api/v2/users from role/service-a-prod while denying DELETE /api/v2/users from the same role. Authorization granularity moves from Layer 4 to Layer 7.
AWS PrivateLink: Eliminating Internet Transit
Every AWS API call that traverses the public internet is an interception opportunity. PrivateLink creates VPC interface endpoints that keep traffic entirely within the AWS backbone:
resource "aws_vpc_endpoint" "s3_interface" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
# Endpoint policy: only this account's buckets, only from this VPC
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "arn:aws:s3:::acme-datalake-*/*"
Condition = {
StringEquals = {
"aws:PrincipalOrgID" = var.org_id
}
}
}]
})
}
Combined with a VPC endpoint policy, you enforce that even traffic flowing through the endpoint is scoped to specific buckets and organizational principals. This prevents a compromised workload from exfiltrating data to an attacker-controlled S3 bucket.
Egress Filtering: The Forgotten Half
Most Zero Trust discussions focus on ingress. Egress is where data actually leaves. A compromised container that can reach 0.0.0.0/0 on port 443 can exfiltrate to any HTTPS endpoint on the internet.
# Egress-only security group - whitelist, not blacklist
resource "aws_security_group" "strict_egress" {
name_prefix = "strict-egress-"
vpc_id = var.vpc_id
# AWS APIs via VPC endpoints only
egress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.vpc_endpoints.id]
description = "AWS APIs via PrivateLink"
}
# Internal service mesh
egress {
from_port = 8443
to_port = 8443
protocol = "tcp"
security_groups = [aws_security_group.mesh_services.id]
description = "mTLS mesh traffic"
}
# DNS
egress {
from_port = 53
to_port = 53
protocol = "udp"
cidr_blocks = [var.vpc_cidr]
description = "VPC DNS resolver"
}
# Everything else: DENIED by default
}
Combine this with AWS Network Firewall or DNS Firewall for domain-level egress filtering when HTTPS inspection is required.
Pillar 3: Workload Identity & East-West Traffic
Within Kubernetes (EKS) or containerized architectures, east-west traffic inside the same VPC, same node, or even the same pod network namespace is untrusted. A compromised sidecar container must not be able to impersonate the primary application container.
Mutual TLS (mTLS)
Unencrypted east-west traffic is a passive eavesdropping vector. mTLS provides both wire encryption and cryptographic identity verification for every service-to-service hop:
┌──────────────┐ ┌──────────────┐
│ Service A │ │ Service B │
│ │ mTLS Handshake │ │
│ ┌────────┐ │◄────────────────────────►│ ┌────────┐ │
│ │Envoy │ │ 1. Present client cert │ │Envoy │ │
│ │Sidecar │ │ 2. Verify server cert │ │Sidecar │ │
│ │ │ │ 3. Check SPIFFE ID │ │ │ │
│ └────────┘ │ 4. Encrypted channel │ └────────┘ │
└──────────────┘ └──────────────┘
│ │
└──────── Certificates from ──────────────┘
SPIRE / Istio CA /
ACM Private CA
Implementation choices and their trade-offs:
| Approach | Pros | Cons |
|---|---|---|
| Istio (Envoy-based mesh) | Full L7 policy, traffic management, observability | High resource overhead (~100-200MB per sidecar), latency added per hop |
| Linkerd | Lighter weight, Rust-based proxy | Smaller ecosystem, fewer L7 features |
| VPC Lattice | AWS-native, no sidecar overhead | Limited to AWS, less granular than mesh L7 policies |
| Cilium (eBPF-based) | Kernel-level, minimal latency impact | Requires newer kernels, steeper learning curve |
EKS Pod Identity: Per-Pod IAM Roles
The anti-pattern: all pods on a node share the node's EC2 instance profile. If any pod is compromised, the attacker inherits the union of all IAM permissions that node needs.
EKS Pod Identity (the successor to IRSA) provides distinct IAM roles per Kubernetes service account:
# Kubernetes ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
name: payment-processor
namespace: payments
---
# EKS Pod Identity Association (via Terraform)
# This binds the K8s SA to a specific IAM role
resource "aws_eks_pod_identity_association" "payment_processor" {
cluster_name = aws_eks_cluster.main.name
namespace = "payments"
service_account = "payment-processor"
role_arn = aws_iam_role.payment_processor.arn
}
resource "aws_iam_role" "payment_processor" {
name = "eks-payment-processor"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "pods.eks.amazonaws.com"
}
Action = ["sts:AssumeRole", "sts:TagSession"]
}]
})
}
# This role can ONLY access the payments DynamoDB table
resource "aws_iam_role_policy" "payment_processor" {
role = aws_iam_role.payment_processor.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"]
Resource = "arn:aws:dynamodb:*:*:table/payments-*"
}]
})
}
Now the payment-processor pod has only DynamoDB access to payment tables. A compromised logging sidecar on the same node cannot access payment data because it runs under a different service account with a different IAM role.
SPIFFE/SPIRE: Cross-Cloud Workload Identity
For hybrid or multi-cloud deployments, cloud-provider-specific identity (IRSA, Pod Identity, GCP Workload Identity) creates lock-in and doesn't work across boundaries. SPIFFE (Secure Production Identity Framework for Everyone) defines a universal workload identity standard:
SPIFFE ID: spiffe://production.example.com/ns/payments/sa/payment-processor
┌────────────────┐ ┌──────────┐ ┌────────────────┐
│ EKS Workload │ │ SPIRE │ │ GCP Workload │
│ │◄──────►│ Server │◄──────►│ │
│ SVID Cert: │ │ │ │ SVID Cert: │
│ 5min TTL │ │ Central │ │ 5min TTL │
│ │ │ Attestor│ │ │
└────────────────┘ └──────────┘ └────────────────┘
SPIRE issues short-lived X.509 certificates (SVIDs) with 5-minute TTLs. Workloads present these for mTLS authentication. The certificate's SPIFFE ID is the identity, not the IP address or cloud IAM role.
Signed Artifact Verification
Zero Trust extends to the software supply chain. An admission controller must verify that container images are:
- Built by your CI/CD pipeline (not pushed manually)
- Cryptographically signed (Sigstore/Cosign, AWS Signer)
- Scanned and clean (no critical CVEs above threshold)
# Kyverno ClusterPolicy: Enforce image signatures
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: Enforce
background: false
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "123456789012.dkr.ecr.*.amazonaws.com/*"
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
attestations:
- type: https://cosign.sigstore.dev/attestation/vuln/v1
conditions:
- all:
- key: "{{ scanner.result.criticalCount }}"
operator: LessThanOrEquals
value: "0"
An unsigned image or one with critical vulnerabilities is rejected at admission time-before it ever runs.
Pillar 4: Dual-Gate Data Protection
This is where Zero Trust gets architecturally interesting. A single compromised IAM role should not be sufficient to access sensitive data. You need orthogonal policy gates that require independent authorization:
Request ──► [Gate 1: Network] ──► [Gate 2: IAM Policy] ──► [Gate 3: Resource Policy] ──► [Gate 4: KMS Key Policy] ──► Data
VPC Endpoint sts:AssumeRole S3 Bucket Policy CMK Decrypt
aws:sourceVpce Principal perms Explicit Allow Explicit Allow
All four gates must independently allow the request. Compromise of any single gate is insufficient.
Implementation: S3 Bucket with Triple-Gate Protection
# Gate 1: S3 bucket policy - requires specific VPC endpoint AND org
resource "aws_s3_bucket_policy" "sensitive_data" {
bucket = aws_s3_bucket.sensitive.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyNonVPCE"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.sensitive.arn,
"${aws_s3_bucket.sensitive.arn}/*"
]
Condition = {
StringNotEquals = {
"aws:sourceVpce" = aws_vpc_endpoint.s3.id
}
}
},
{
Sid = "DenyOutsideOrg"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.sensitive.arn,
"${aws_s3_bucket.sensitive.arn}/*"
]
Condition = {
StringNotEquals = {
"aws:PrincipalOrgID" = var.org_id
}
}
},
{
Sid = "AllowAuthorizedRoles"
Effect = "Allow"
Principal = {
AWS = var.authorized_role_arns
}
Action = ["s3:GetObject"]
Resource = "${aws_s3_bucket.sensitive.arn}/*"
}
]
})
}
# Gate 2: KMS key policy - independent authorization
resource "aws_kms_key" "data_key" {
description = "CMK for sensitive data encryption"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "KeyAdministration"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.account_id}:role/SecurityAdmin" }
Action = ["kms:Create*", "kms:Describe*", "kms:Enable*", "kms:List*",
"kms:Put*", "kms:Update*", "kms:Revoke*", "kms:Disable*",
"kms:Get*", "kms:Delete*", "kms:ScheduleKeyDeletion"]
Resource = "*"
},
{
Sid = "KeyUsage"
Effect = "Allow"
Principal = { AWS = var.authorized_role_arns }
Action = ["kms:Decrypt", "kms:GenerateDataKey"]
Resource = "*"
Condition = {
StringEquals = {
"kms:ViaService" = "s3.${var.region}.amazonaws.com"
"aws:PrincipalOrgID" = var.org_id
}
}
}
]
})
}
# Gate 3: Enforce SSE-KMS on all objects
resource "aws_s3_bucket_server_side_encryption_configuration" "sensitive" {
bucket = aws_s3_bucket.sensitive.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data_key.arn
}
bucket_key_enabled = true
}
}
Why this matters: An attacker who compromises an IAM role with s3:GetObject permissions still cannot read the data unless:
- They are calling from within the designated VPC endpoint (network gate)
- The S3 bucket policy explicitly allows their principal (resource policy gate)
- The KMS key policy grants them
kms:Decrypt(encryption gate) - Their principal belongs to the correct AWS Organization (organizational gate)
Compromise of credentials alone is insufficient. The attacker must also be in the right network position, which dramatically raises the difficulty.
Pillar 5: Continuous Verification & Blast Radius Containment
Trust is never binary or permanent. It is continuously evaluated and immediately revoked when anomalies are detected.
Automated Credential Revocation Pipeline
GuardDuty Finding ──► EventBridge Rule ──► Lambda ──► Revoke STS Sessions
(Anomalous API call) (Pattern match) (Automated) (Immediate)
# Lambda: Revoke all active sessions for a compromised IAM role
import boto3
import json
from datetime import datetime, timezone
iam = boto3.client('iam')
def handler(event, context):
detail = event['detail']
finding_type = detail['type']
# GuardDuty findings indicating credential compromise
compromise_indicators = [
'UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration',
'UnauthorizedAccess:IAMUser/TorIPCaller',
'Discovery:S3/MaliciousIPCaller',
'UnauthorizedAccess:IAMUser/MaliciousIPCaller.Custom',
]
if finding_type not in compromise_indicators:
return
# Extract the compromised principal
resource = detail['resource']
access_key_id = resource.get('accessKeyDetails', {}).get('accessKeyId')
principal_id = resource.get('accessKeyDetails', {}).get('principalId')
user_name = resource.get('accessKeyDetails', {}).get('userName')
# Revoke all sessions by applying an inline deny-all policy
# with a time condition that invalidates tokens issued before now
revocation_policy = {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"DateLessThan": {
"aws:TokenIssueTime": datetime.now(timezone.utc).isoformat()
}
}
}]
}
# Apply to the role (for assumed-role principals)
role_name = extract_role_name(principal_id)
if role_name:
iam.put_role_policy(
RoleName=role_name,
PolicyName='EmergencySessionRevocation',
PolicyDocument=json.dumps(revocation_policy)
)
print(f"Revoked all sessions for role: {role_name}")
# Notify security team
sns = boto3.client('sns')
sns.publish(
TopicArn='arn:aws:sns:us-east-1:123456789012:security-alerts',
Subject=f'CREDENTIAL COMPROMISE: {role_name or user_name}',
Message=json.dumps(detail, indent=2, default=str)
)
def extract_role_name(principal_id):
"""Extract role name from principal ID (AROA...)"""
if not principal_id:
return None
# For assumed roles, principal_id format: AROAEXAMPLE:session-name
# Look up the role via the access key or other detail
# Implementation depends on your naming conventions
pass
The aws:TokenIssueTime condition is the critical mechanism. STS tokens cannot be individually revoked. Instead, you attach an inline policy that denies all actions for tokens issued before the current timestamp. All existing sessions are immediately invalidated. New sessions (post-revocation) are unaffected.
Service Control Policies: Immutable Guardrails
SCPs are non-negotiable boundaries that even account administrators cannot override:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PreventCloudTrailDisable",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail"
],
"Resource": "*",
"Condition": {
"StringNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/OrganizationSecurityAudit"
}
}
},
{
"Sid": "PreventKMSKeyDeletion",
"Effect": "Deny",
"Action": [
"kms:ScheduleKeyDeletion",
"kms:DisableKey"
],
"Resource": "*",
"Condition": {
"StringNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/KMSAdministrator"
}
}
},
{
"Sid": "EnforceIMDSv2",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringNotEquals": {
"ec2:MetadataHttpTokens": "required"
}
}
},
{
"Sid": "DenyPublicS3",
"Effect": "Deny",
"Action": [
"s3:PutBucketPublicAccessBlock",
"s3:PutAccountPublicAccessBlock"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"s3:PublicAccessBlockConfiguration/BlockPublicAcls": "true"
}
}
}
]
}
Key SCPs for Zero Trust:
- Prevent CloudTrail tampering - an attacker who disables logging can operate undetected
- Enforce IMDSv2 - IMDSv1 is an SSRF-to-credential-theft vector
- Prevent KMS key deletion - destroying encryption keys is an irreversible data destruction attack
- Block public S3 - no bucket should ever be publicly accessible in a Zero Trust environment
IAM Access Analyzer: Continuous Permission Auditing
IAM Access Analyzer identifies two classes of risk:
- External access: Resources (S3 buckets, KMS keys, SQS queues, Lambda functions) with policies granting access to principals outside your AWS Organization
- Unused access: IAM roles and users with permissions they haven't exercised in 90+ days
resource "aws_accessanalyzer_analyzer" "org_level" {
analyzer_name = "organization-analyzer"
type = "ORGANIZATION"
# Generates findings for any resource accessible outside the org
}
resource "aws_accessanalyzer_analyzer" "unused_access" {
analyzer_name = "unused-access-analyzer"
type = "ACCOUNT_UNUSED_ACCESS"
configuration {
unused_access {
unused_access_age = 90
}
}
}
Feed findings into Security Hub, and set up automated remediation for high-severity findings.
The Reality Check: Common Failure Modes
1. The "Network Doesn't Matter" Trap
An overcorrection where engineers ignore VPC routing, Security Group discipline, and egress filtering because "IAM and TLS handle security."
Why this fails: If an application-layer token leaks (JWT in a log, STS credential in a stack trace), network controls are your last line of defense. A VPC endpoint condition in the S3 bucket policy means stolen credentials are useless outside your VPC. Egress filtering prevents a compromised container from reaching a C2 server. Defense-in-depth requires both identity and network controls.
2. Policy Complexity Explosion
A 200-microservice architecture where each service has its own IAM role, KMS key access, S3 bucket policy entry, VPC Lattice auth policy, and Kubernetes NetworkPolicy generates thousands of policy documents. Without automation, this becomes:
- Unauditable: No human can review 2,000 IAM policies for correctness
- Brittle: A single typo in a resource ARN causes a production outage
- Slow: Adding a new service requires changes in 5+ policy systems
Mitigation: Policy-as-code with automated generation. Use Terraform modules, OPA/Rego policy validation, and CI/CD pipelines that lint and test IAM policies before deployment.
3. The Shared Role Shortcut
The most common Zero Trust failure in practice. Teams adopt Zero Trust on paper but run all pods under one or two broad IAM roles because managing per-service roles is operationally expensive.
# What the architecture diagram shows:
Pod A → Role A (scoped to DynamoDB)
Pod B → Role B (scoped to S3)
Pod C → Role C (scoped to SQS)
# What actually runs in production:
Pod A → shared-role (DynamoDB + S3 + SQS + KMS + SNS + ...)
Pod B → shared-role
Pod C → shared-role
Mitigation: Automated IAM policy generation from application code analysis. Tools like iamlive can record actual AWS API calls during development and generate least-privilege policies. Pair with IAM Access Analyzer's unused access findings to continuously right-size permissions.
4. Performance Impact of Per-Request Auth
Enforcing SigV4 authentication on high-throughput east-west microservice calls adds CPU overhead (HMAC-SHA256 computation) and latency (additional auth lookups). At 10,000 RPS between two services, this is measurable.
Mitigation: - Connection reuse: mTLS amortizes the TLS handshake cost over many requests on a persistent connection - Token caching: STS credentials are cached for their lifetime (typically 1 hour); not fetched per-request - Benchmarking: VPC Lattice and mesh proxies must be load-tested against your latency SLOs before production deployment - Selective enforcement: Not all east-west traffic requires the same level of verification. Health checks and metrics scraping may use simpler auth
Maturity Model: Where to Start
Zero Trust is a spectrum, not a binary state. A practical adoption path:
| Level | Focus | Key Actions |
|---|---|---|
| L0: Foundations | Credential hygiene | Eliminate IAM users, enforce MFA, enable CloudTrail, IMDSv2 everywhere |
| L1: Perimeter Hardening | Ingress/egress control | SSM Session Manager, VPC endpoints for AWS services, strict SG egress |
| L2: Workload Identity | Per-workload IAM | EKS Pod Identity, OIDC for CI/CD, per-service IAM roles |
| L3: Data Protection | Dual-gate access | S3 bucket policies + KMS key policies with VPC endpoint conditions |
| L4: East-West Encryption | mTLS mesh | Service mesh or VPC Lattice for all inter-service traffic |
| L5: Continuous Verification | Automated response | GuardDuty → automated revocation, IAM Access Analyzer, Config rules |
Most organizations find substantial security improvement at L2-L3 before tackling the operational complexity of L4-L5.
Closing Thought
Zero Trust on AWS is not a product, a checkbox, or a one-time migration. It's the sustained engineering discipline of treating every component-including your own infrastructure-as potentially compromised. The five pillars reinforce each other: identity without network controls is insufficient, network controls without identity are bypassable, and both without continuous verification give you a false sense of security.
The hardest part is not the technology. AWS provides all the primitives. The hardest part is the organizational discipline to maintain fine-grained policies at scale without collapsing back to shared roles and broad permissions under delivery pressure.