tech/aws/security

SECURITY

AWS security skill. Use when: (1) designing IAM policies — least-privilege roles, resource-based policies,

production AWS CLI v2, CDK v2, SDK v3 (TypeScript/Python), Terraform
requires: tech/aws
improves: tech/awstech

AWS Security

Security on AWS is not a feature you bolt on — it is a design constraint applied from the first resource. This skill covers IAM, VPC hardening, secrets management, encryption, and continuous audit for production workloads.

1. IAM Fundamentals

Principal types

PrincipalDescription
IAM UserLong-lived human identity. Avoid in production; prefer roles.
IAM RoleAssumed temporarily. Use for EC2, Lambda, ECS, cross-account.
IAM GroupCollection of users sharing policies. No group-to-group nesting.
Service principalAWS service (e.g. lambda.amazonaws.com) acting on your behalf.

Policy types

TypeAttached toNotes
Identity-basedUser, role, groupGrant permissions to the principal
Resource-basedS3 bucket, KMS key, LambdaGrant cross-account access without assume-role
Permission boundaryUser or roleHard ceiling — even if identity policy allows more, boundary wins
SCP (Service Control Policy)AWS Organizations OU/accountGuardrails across the entire account; does not affect root user
Session policyPassed during sts:AssumeRoleInline restriction scoped to a single session

Policy evaluation logic

1. Explicit DENY anywhere → denied (SCPs, resource-based, identity-based)
2. SCP allows the action? No → denied
3. Resource-based policy allows (same account)? → allowed without identity policy needed
4. Identity-based policy + permission boundary BOTH allow? → allowed
5. Implicit deny (nothing allows) → denied

The golden rule: an explicit deny always wins, no matter how many allows exist elsewhere.

ARN format

arn:partition:service:region:account-id:resource-type/resource-id

# Examples
arn:aws:iam::123456789012:role/my-lambda-role
arn:aws:s3:::my-bucket                          # S3 has no region or account in ARN
arn:aws:dynamodb:af-south-1:123456789012:table/Orders
arn:aws:kms:af-south-1:123456789012:key/mrk-abc123

JSON policy structure

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOrdersTable",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:Query",
        "dynamodb:BatchGetItem"
      ],
      "Resource": "arn:aws:dynamodb:af-south-1:123456789012:table/Orders",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "af-south-1"
        }
      }
    }
  ]
}

Always include "Version": "2012-10-17" — this is a literal string, not a date to update.


2. Least-Privilege IAM Patterns

Lambda execution role

A Lambda function only needs what it explicitly uses. Never attach AdministratorAccess or even PowerUserAccess.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:af-south-1:123456789012:log-group:/aws/lambda/my-function:*"
    },
    {
      "Sid": "S3ReadInput",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-input-bucket/*"
    },
    {
      "Sid": "DynamoWrite",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:UpdateItem"],
      "Resource": "arn:aws:dynamodb:af-south-1:123456789012:table/Orders"
    }
  ]
}

Trust policy (who can assume this role):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "lambda.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
# Create the role with trust policy
aws iam create-role \
  --role-name my-lambda-role \
  --assume-role-policy-document file://trust-policy.json

# Create and attach the permission policy
aws iam put-role-policy \
  --role-name my-lambda-role \
  --policy-name LambdaPermissions \
  --policy-document file://lambda-policy.json

ECS task role vs execution role — critical distinction

This is the most commonly confused pattern in ECS security:

RolePurposeWho uses it
Task execution roleLets ECS pull images from ECR and write logs to CloudWatchThe ECS agent (infrastructure layer)
Task rolePermissions your application code uses at runtime (S3, DynamoDB, etc.)Your container process

Never conflate them. If your app needs to read from S3, that permission goes on the task role, not the execution role.

# Execution role — needs ecr:GetAuthorizationToken, logs:CreateLogStream, etc.
aws iam attach-role-policy \
  --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

# Task role — your app permissions (example: read from S3)
aws iam put-role-policy \
  --role-name my-ecs-task-role \
  --policy-name AppPermissions \
  --policy-document file://app-policy.json

Cross-account assume-role pattern

Account A (source) assumes a role in Account B (target).

Account B — the role being assumed (trust policy):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT_A_ID:role/source-role"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "af-south-1"
        },
        "StringLike": {
          "aws:SourceVpc": "vpc-0abc123"
        }
      }
    }
  ]
}

Account A — permission to call assume-role:

{
  "Effect": "Allow",
  "Action": "sts:AssumeRole",
  "Resource": "arn:aws:iam::ACCOUNT_B_ID:role/target-role"
}
# Assume the role and export temporary credentials
CREDS=$(aws sts assume-role \
  --role-arn arn:aws:iam::ACCOUNT_B_ID:role/target-role \
  --role-session-name deploy-session \
  --query 'Credentials' --output json)

export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .AccessKeyId)
export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .SecretAccessKey)
export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .SessionToken)

Useful condition keys

KeyUse case
aws:RequestedRegionRestrict actions to specific regions
aws:SourceVpcRestrict S3 or API access to requests from a specific VPC
aws:SourceIpIP allowlist (be careful with NAT gateways — use CIDR)
aws:CalledViaEnsure action was invoked by a trusted AWS service, not directly
aws:MultiFactorAuthPresentRequire MFA for sensitive IAM/billing actions

3. VPC Security

Security groups vs NACLs

FeatureSecurity GroupsNACLs
LevelInstance / ENISubnet
StateStateful — return traffic auto-allowedStateless — must allow inbound AND outbound explicitly
RulesAllow onlyAllow + Deny
EvaluationAll rules evaluatedRules evaluated in order (lowest number first)
DefaultDeny all inbound, allow all outboundAllow all (default VPC NACL)

Security groups are your primary tool. NACLs are a coarse secondary layer — use them to block known bad CIDR ranges at subnet level.

Common ports reference

PortProtocolService
22TCPSSH — restrict to bastion SG or VPN CIDR only
80 / 443TCPHTTP / HTTPS — allow from ALB SG or 0.0.0.0/0
5432TCPPostgreSQL — allow from app tier SG only
3306TCPMySQL — allow from app tier SG only
6379TCPRedis (ElastiCache) — allow from app tier SG only
2049TCPNFS (EFS) — allow from ECS/EC2 SG only

Reference-by-security-group pattern

Never use IP ranges to allow traffic between tiers. Reference security groups instead — it survives IP changes and is semantically clear.

# Create security groups
aws ec2 create-security-group \
  --group-name alb-sg \
  --description "ALB inbound from internet" \
  --vpc-id vpc-0abc123

aws ec2 create-security-group \
  --group-name app-sg \
  --description "App tier — only from ALB" \
  --vpc-id vpc-0abc123

aws ec2 create-security-group \
  --group-name db-sg \
  --description "DB tier — only from app tier" \
  --vpc-id vpc-0abc123

# Allow HTTPS into ALB from internet
aws ec2 authorize-security-group-ingress \
  --group-id sg-alb-id \
  --protocol tcp --port 443 --cidr 0.0.0.0/0

# Allow app tier only from ALB SG (not by IP)
aws ec2 authorize-security-group-ingress \
  --group-id sg-app-id \
  --protocol tcp --port 8080 \
  --source-group sg-alb-id

# Allow DB only from app tier SG
aws ec2 authorize-security-group-ingress \
  --group-id sg-db-id \
  --protocol tcp --port 5432 \
  --source-group sg-app-id

Private subnet + NAT gateway pattern

Internet
    │
[IGW]
    │
[Public Subnet]  ← ALB, NAT Gateway, Bastion
    │
[Private Subnet] ← Lambda, ECS Tasks, RDS, ElastiCache
    │ (outbound only via NAT GW)
[NAT Gateway]

Private subnet resources have no public IP and are unreachable from the internet. They reach the internet (for package updates, external APIs) via NAT Gateway — which is public-facing but only allows outbound-initiated connections.

VPC endpoints — avoid internet traffic for AWS services

Without endpoints, traffic from your Lambda/ECS to S3 or DynamoDB exits to the internet (even through a NAT Gateway). VPC endpoints keep traffic on the AWS backbone.

# Gateway endpoint for S3 (free)
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.af-south-1.s3 \
  --vpc-endpoint-type Gateway \
  --route-table-ids rtb-0abc123

# Gateway endpoint for DynamoDB (free)
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.af-south-1.dynamodb \
  --vpc-endpoint-type Gateway \
  --route-table-ids rtb-0abc123

# Interface endpoint for Secrets Manager (costs ~$7.50/month/AZ)
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.af-south-1.secretsmanager \
  --vpc-endpoint-type Interface \
  --subnet-ids subnet-private-1 subnet-private-2 \
  --security-group-ids sg-endpoint-id \
  --private-dns-enabled

Gateway endpoints (S3, DynamoDB) are free and always worth enabling. Interface endpoints cost per AZ per hour — evaluate based on traffic volume and compliance requirements.


4. Secrets Manager

Never store secrets in environment variables, code, or configuration files. Use Secrets Manager for anything that rotates or is high-value (DB passwords, API keys, OAuth tokens).

# Create a secret
aws secretsmanager create-secret \
  --name prod/myapp/db-password \
  --description "RDS master password" \
  --secret-string '{"username":"admin","password":"s3cr3t!"}' \
  --kms-key-id arn:aws:kms:af-south-1:123456789012:key/mrk-abc123

# Enable automatic rotation (requires a Lambda rotation function)
aws secretsmanager rotate-secret \
  --secret-id prod/myapp/db-password \
  --rotation-lambda-arn arn:aws:lambda:af-south-1:123456789012:function:SecretsRotator \
  --rotation-rules AutomaticallyAfterDays=30

# Retrieve (CLI)
aws secretsmanager get-secret-value \
  --secret-id prod/myapp/db-password \
  --query SecretString --output text

Retrieval in Lambda with caching (TypeScript)

Calling Secrets Manager on every Lambda invocation adds latency and cost. Cache in module scope — the Lambda execution environment is reused across warm invocations.

import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'af-south-1' });

// Module-level cache — persists across warm invocations
let cachedSecret: { username: string; password: string } | null = null;
let cacheExpiry = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

async function getDbCredentials() {
  if (cachedSecret && Date.now() < cacheExpiry) {
    return cachedSecret;
  }

  const response = await client.send(
    new GetSecretValueCommand({ SecretId: 'prod/myapp/db-password' })
  );

  cachedSecret = JSON.parse(response.SecretString!);
  cacheExpiry = Date.now() + CACHE_TTL_MS;
  return cachedSecret!;
}

export const handler = async (event: unknown) => {
  const { username, password } = await getDbCredentials();
  // use credentials...
};

Secrets Manager vs Parameter Store

FeatureSecrets ManagerParameter Store (SecureString)
Cost$0.40/secret/month + $0.05/10k API callsFree (standard), $0.05/10k advanced
Automatic rotationYes (built-in Lambda-based)No (manual)
VersioningYesYes
Cross-accountYes (resource-based policy)No
Max size65 KB4 KB (standard), 8 KB (advanced)
When to useDB passwords, OAuth tokens, anything that rotatesConfig values, feature flags, ARNs, non-rotating secrets

Use Secrets Manager for secrets that rotate or need cross-account access. Use Parameter Store SecureString for application configuration that happens to be sensitive but is static.


5. KMS

CMK vs AWS-managed keys

TypeControlKey policyCost
AWS-managed (e.g. aws/s3)AWS rotates automaticallyCannot editFree
Customer-managed (CMK)You control rotation, policy, grantsFull control$1/key/month + $0.03/10k API calls

Use CMKs when you need: audit of every encrypt/decrypt, cross-account access, key deletion control, or granular grants.

Key policy

Every CMK has a key policy. Without a key policy statement allowing the account root, no IAM policy can grant KMS access — the key policy is the gatekeeper.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnableIAMPolicies",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::123456789012:root" },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AllowLambdaEncryptDecrypt",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::123456789012:role/my-lambda-role" },
      "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
      "Resource": "*"
    }
  ]
}

Envelope encryption concept

KMS does not encrypt your data directly for large payloads. It uses envelope encryption:

1. Your app calls GenerateDataKey → KMS returns plaintext data key + encrypted data key
2. Your app encrypts data locally with the plaintext data key (AES-256)
3. Store encrypted data + encrypted data key together
4. To decrypt: call KMS Decrypt with the encrypted data key → get plaintext key → decrypt data locally

The AWS SDKs handle this automatically for S3, EBS, and Secrets Manager. You only need to implement it manually for custom encryption.

# Encrypt a small value directly (< 4KB)
aws kms encrypt \
  --key-id arn:aws:kms:af-south-1:123456789012:key/mrk-abc123 \
  --plaintext fileb://secret.txt \
  --output text --query CiphertextBlob | base64 --decode > secret.enc

# Decrypt
aws kms decrypt \
  --ciphertext-blob fileb://secret.enc \
  --output text --query Plaintext | base64 --decode

# Enable automatic key rotation (annually)
aws kms enable-key-rotation --key-id mrk-abc123

KMS in practice


6. CloudTrail

CloudTrail records every AWS API call — who did what, when, from where. It is the non-negotiable audit foundation.

# Create a trail covering all regions
aws cloudtrail create-trail \
  --name org-audit-trail \
  --s3-bucket-name my-cloudtrail-bucket \
  --is-multi-region-trail \
  --include-global-service-events \
  --enable-log-file-validation

# Start logging
aws cloudtrail start-logging --name org-audit-trail

# Send to CloudWatch Logs (for alerting)
aws cloudtrail update-trail \
  --name org-audit-trail \
  --cloud-watch-logs-log-group-arn arn:aws:logs:af-south-1:123456789012:log-group:CloudTrail \
  --cloud-watch-logs-role-arn arn:aws:iam::123456789012:role/CloudTrailToCloudWatch

Example CloudTrail event structure

{
  "eventVersion": "1.08",
  "userIdentity": {
    "type": "IAMUser",
    "principalId": "AIDAEXAMPLE",
    "arn": "arn:aws:iam::123456789012:user/alice",
    "accountId": "123456789012",
    "userName": "alice"
  },
  "eventTime": "2026-04-02T08:23:11Z",
  "eventSource": "iam.amazonaws.com",
  "eventName": "CreateRole",
  "awsRegion": "af-south-1",
  "sourceIPAddress": "197.x.x.x",
  "requestParameters": { "roleName": "new-admin-role" },
  "responseElements": { ... }
}

Key events to alert on

Create CloudWatch Metric Filters on the CloudTrail log group for these events:

EventWhy it matters
ConsoleLogin where userIdentity.type = RootRoot login is always suspicious
CreateUser, AttachUserPolicy, CreateAccessKeyUnexpected IAM changes
AuthorizeSecurityGroupIngressSecurity group opened — may expose services
DeleteTrail, StopLogging, PutEventSelectorsAudit evasion
KMSDisableKey, KMSScheduleKeyDeletionRansomware precursor
PutBucketPolicy with "Principal": "*"Public S3 exposure
# Example: alert on root login
aws logs put-metric-filter \
  --log-group-name CloudTrail \
  --filter-name RootLogin \
  --filter-pattern '{ $.userIdentity.type = "Root" && $.eventName = "ConsoleLogin" }' \
  --metric-transformations metricName=RootLoginCount,metricNamespace=Security,metricValue=1

7. GuardDuty and Security Hub

GuardDuty

GuardDuty is a continuous threat detection service. It analyses CloudTrail, VPC Flow Logs, and DNS logs using ML models — you don't configure rules, just enable it.

# Enable GuardDuty
aws guardduty create-detector --enable --finding-publishing-frequency FIFTEEN_MINUTES

# Get detector ID
DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)

# List active findings
aws guardduty list-findings --detector-id $DETECTOR_ID \
  --finding-criteria '{"Criterion":{"service.archived":{"Eq":["false"]}}}'

Finding categories:

CategoryExample
CryptoCurrencyCryptoCurrency:EC2/BitcoinTool.B — instance mining crypto
UnauthorizedAccessUnauthorizedAccess:IAMUser/ConsoleLoginSuccess.B — login from unusual location
ReconRecon:EC2/PortProbeUnprotectedPort — port scanning your instance
TrojanTrojan:EC2/DNSDataExfiltration — DNS-based data exfiltration
PersistencePersistence:IAMUser/AnomalousBehavior — unusual IAM activity

EventBridge rule to forward findings to SNS

# Create SNS topic for security alerts
aws sns create-topic --name security-alerts
aws sns subscribe \
  --topic-arn arn:aws:sns:af-south-1:123456789012:security-alerts \
  --protocol email --notification-endpoint security@myorg.com

# EventBridge rule: high-severity GuardDuty findings → SNS
aws events put-rule \
  --name GuardDutyHighSeverity \
  --event-pattern '{
    "source": ["aws.guardduty"],
    "detail-type": ["GuardDuty Finding"],
    "detail": { "severity": [{ "numeric": [">=", 7] }] }
  }' \
  --state ENABLED

aws events put-targets \
  --rule GuardDutyHighSeverity \
  --targets '[{
    "Id": "SendToSNS",
    "Arn": "arn:aws:sns:af-south-1:123456789012:security-alerts"
  }]'

For Slack: use an SNS → Lambda → Slack webhook chain, or SNS → EventBridge → Lambda.

Security Hub

Security Hub aggregates findings from GuardDuty, Macie, Inspector, IAM Access Analyzer, and AWS Config into a single dashboard with compliance scores.

# Enable Security Hub
aws securityhub enable-security-hub \
  --enable-default-standards

# Enable CIS AWS Foundations Benchmark
aws securityhub batch-enable-standards \
  --standards-subscription-requests \
    '[{"StandardsArn":"arn:aws:securityhub:af-south-1::standards/cis-aws-foundations-benchmark/v/1.4.0"}]'

# Enable AWS Foundational Security Best Practices
aws securityhub batch-enable-standards \
  --standards-subscription-requests \
    '[{"StandardsArn":"arn:aws:securityhub:af-south-1::standards/aws-foundational-security-best-practices/v/1.0.0"}]'

# List failed controls
aws securityhub get-findings \
  --filters '{"ComplianceStatus":[{"Value":"FAILED","Comparison":"EQUALS"}]}' \
  --query 'Findings[].{Title:Title,Severity:Severity.Label}' \
  --output table

8. Securing Lambda

Absolute rules

VPC placement tradeoffs

Placing Lambda in a VPC gives access to RDS, ElastiCache, and internal services — but adds a cold start penalty (ENI attachment, ~500ms–1s historically; reduced with Hyperplane ENIs since 2020 but still non-zero in some regions).

Decision: Use VPC placement when the function needs private network access (RDS, ElastiCache). For functions only calling AWS APIs or the internet, keep Lambda out of the VPC and use IAM + VPC endpoints instead.

# Deploy Lambda in a VPC
aws lambda update-function-configuration \
  --function-name my-function \
  --vpc-config SubnetIds=subnet-private-1,subnet-private-2,SecurityGroupIds=sg-lambda-id

Function URL auth

# Public URL (no auth) — only use for webhooks with HMAC validation in code
aws lambda create-function-url-config \
  --function-name my-function \
  --auth-type NONE

# IAM-authenticated URL — caller must SigV4-sign the request
aws lambda create-function-url-config \
  --function-name my-function \
  --auth-type AWS_IAM

Resource policy for cross-account invocation

# Allow account B to invoke this function
aws lambda add-permission \
  --function-name my-function \
  --statement-id CrossAccountInvoke \
  --action lambda:InvokeFunction \
  --principal arn:aws:iam::ACCOUNT_B_ID:role/invoker-role

9. Securing ECS / Fargate

Role separation (see section 2 for detail)

// Task definition excerpt
{
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn":      "arn:aws:iam::123456789012:role/my-app-task-role",
  ...
}

Inject secrets from Secrets Manager — never pass as plain env vars

{
  "containerDefinitions": [
    {
      "name": "app",
      "image": "123456789012.dkr.ecr.af-south-1.amazonaws.com/myapp:latest",
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:af-south-1:123456789012:secret:prod/myapp/db-password:password::"
        }
      ],
      "environment": [
        { "name": "APP_ENV", "value": "production" }
      ]
    }
  ]
}

The ECS agent resolves the secret at launch time and injects it as an environment variable inside the container. The secret value is never visible in the task definition JSON stored in ECS.

Dockerfile hardening

FROM node:20-alpine

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --omit=dev

COPY --chown=appuser:appgroup . .

# Switch to non-root before running
USER appuser

EXPOSE 8080
CMD ["node", "server.js"]

Enable read-only root filesystem in the task definition:

{
  "readonlyRootFilesystem": true,
  "mountPoints": [
    { "containerPath": "/tmp", "sourceVolume": "tmp-volume" }
  ]
}

10. POPIA and AWS Data Residency (South Africa)

The Protection of Personal Information Act (POPIA) requires personal information of South African data subjects to be processed lawfully, and cross-border transfers require adequate protection or consent.

af-south-1 (Cape Town) — primary region for SA data

# Default all new resources to af-south-1
aws configure set region af-south-1

# Verify S3 bucket is in Cape Town
aws s3api get-bucket-location --bucket my-sa-bucket

S3 bucket policy blocking replication out of af-south-1

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyNonCapeTownReplication",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:ReplicateObject",
      "Resource": "arn:aws:s3:::my-sa-bucket/*",
      "Condition": {
        "StringNotEquals": {
          "s3:LocationConstraint": "af-south-1"
        }
      }
    }
  ]
}

SCP to prevent data leaving the region

Apply at the Organizations OU level for SA-data accounts:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideAfSouth1",
      "Effect": "Deny",
      "Action": [
        "s3:CreateBucket",
        "rds:CreateDBInstance",
        "dynamodb:CreateTable",
        "ec2:RunInstances"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": "af-south-1"
        }
      }
    }
  ]
}

Services not yet in af-south-1

Some services are not available in af-south-1 as of early 2026 — notably portions of Bedrock, some Macie features, and certain Cognito advanced features. When you must use a service outside af-south-1 for a SA-data workload:

  1. Conduct a POPIA cross-border transfer assessment
  2. Ensure the destination country has adequate protection (EU is generally acceptable)
  3. Document the necessity and implement access controls to minimise exposure
  4. Prefer eu-west-1 (Ireland) or eu-central-1 (Frankfurt) over US regions for SA cross-border transfers

11. Gotchas

IAM eventual consistency (up to 60 seconds) IAM changes propagate globally but not instantly. If a CI/CD pipeline creates a role and immediately uses it in the next step, it may fail with AccessDenied. Add a sleep 10 after create-role or poll with retries in automation.

Resource-based policy vs identity-based policy precedence For cross-account access, both the identity-based policy (in the caller's account) AND the resource-based policy (on the target resource) must allow the action. For same-account access, either one is sufficient — the resource-based policy alone can grant access without any identity-based policy.

SCPs do not affect the root user An SCP with "Effect": "Deny", "Action": "*" applied to an account will restrict all IAM principals — but the root user of that account is immune. Protect root accounts with MFA, no access keys, and limit root use to billing and account recovery only.

Security group changes are immediate; NACL changes are not Security group rule changes take effect immediately for new and existing connections (stateful). NACL changes affect new packets only — existing sessions may complete under old rules depending on connection state.

Secrets Manager costs add up

KMS key deletion is irreversible after waiting period

Lambda execution role is evaluated at invocation, not at deploy time You can deploy a Lambda with an overly permissive role and tests will pass. The role is only checked when the API call is actually made at runtime. Write integration tests that verify the function succeeds with its actual role, not with your developer credentials.

S3 public block settings are account-level and bucket-level Even if a bucket policy allows public access, the S3 Block Public Access setting (account or bucket level) overrides it. Conversely, if Block Public Access is off, a permissive bucket policy or ACL can make objects public. Enable Block Public Access at the account level as a default.

# Enable account-level S3 Block Public Access
aws s3control put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true