Notes · Learn AWS · CHAPTER 8

Security & Secrets

KMS, Secrets Manager, ACM, GuardDuty, Security Hub, Access Analyzer - the bones of an AWS security posture. The biggest mental jolt for Azure devs: AWS encrypts less by default than Azure does, and most encryption is opt-in or opt-out per service. The good news: every primitive is a first-class API you can wire up with Terraform.

In Azure, you usually inherit a lot of security for free - storage accounts encrypt by default with platform-managed keys, Key Vault is wired into most PaaS resources, Defender for Cloud lights up everywhere once you toggle it on. AWS is more "bring your own posture": KMS exists, but you decide what's encrypted with which key; Secrets Manager exists, but you decide which secrets are worth $0.40/month; GuardDuty exists, but you pay per GB of logs analyzed. This chapter is a tour of the security primitives plus the gotchas that bite Azure devs in the first month.

In this chapter
  1. The cheat table
  2. KMS keys and envelope encryption
  3. Secrets Manager vs Parameter Store
  4. ACM: free TLS certs
  5. GuardDuty: managed threat detection
  6. Security Hub & Access Analyzer
  7. Try it: peek at your security posture
  8. Quick check (quiz)
  9. Gotchas for Azure devs
  10. Project Compass: KMS + Secrets
  11. Recap & next

The cheat table: Azure security → AWS security

ConceptAzureAWS
Key vault for crypto keysKey Vault (keys)KMS (Key Management Service)
Secret storeKey Vault (secrets)Secrets Manager or SSM Parameter Store (SecureString)
App-managed certificatesApp Service managed certs, Key Vault certsACM (AWS Certificate Manager) - free for public, paid for private CA
Hardware-backed keysKey Vault Premium / Managed HSMKMS (HSM-backed by default) + CloudHSM (single-tenant)
Workload threat detectionDefender for Cloud (CWPP)GuardDuty
Posture / compliance aggregationDefender for Cloud (CSPM) / Secure ScoreSecurity Hub
SIEMMicrosoft SentinelSecurity Hub + Detective (or third-party: Splunk, Datadog)
Data classificationMicrosoft Purview / Information ProtectionMacie (S3-focused PII discovery)
Web application firewallFront Door WAF / App Gateway WAFAWS WAF (attached to ALB, CloudFront, API GW, AppSync)
DDoS protectionDDoS Protection StandardShield Standard (free) / Shield Advanced (paid)
Private connectivityPrivate Link / Private EndpointPrivateLink / VPC Endpoints (interface & gateway)
Find over-shared resourcesNo clean single tool; relies on DefenderIAM Access Analyzer
Audit trailActivity LogCloudTrail (we cover in chapter 9)
Default encryption at restOn by default everywhere (platform-managed keys)On for most services, but historically opt-in per resource. Defaults have tightened over time.
The row to internalize: "default encryption at rest". In Azure, storage and DB encryption is on by default and most teams never think about it. In AWS, S3, EBS, and RDS have had to add "default encryption" toggles at the account level, and historically you could create unencrypted resources. Today new accounts default to encrypted, but old accounts and old IaC modules might not. Always verify encryption is on - don't assume.
What's in a name? - the security service glossary
KMS
Key Management Service. Launched Nov 2014. The keys themselves never leave AWS HSMs - you only ever ask KMS to encrypt or decrypt for you. The closest Azure cousin is Key Vault Managed HSM, but KMS is the default everyone uses.
ACM
AWS Certificate Manager. Launched Jan 2016. Free public TLS certs for any AWS service that terminates TLS (ALB, CloudFront, API Gateway). The whole product was a deliberate nudge to make HTTPS the default - by giving certs away, AWS made the "we'll do HTTP for now" argument economically silly.
GuardDuty
Named "Guard" + "Duty" - the watchful sentry standing guard over your account, on duty 24/7. Launched re:Invent 2017. Eats CloudTrail + VPC Flow Logs + DNS logs and emits findings like "your EC2 is talking to a known crypto-mining IP" or "an IAM key is being used from a country you've never used before."
Macie
Named (apocryphally) after one of the AWS founders' family members. Launched Aug 2017, originally an ML-driven all-services data-classification tool, refactored in 2020 to be S3-focused. Scans buckets for PII patterns (credit cards, SSNs, secrets) and tells you which buckets are risky.
Inspector
The vulnerability scanner. v1 scanned EC2 hosts via agent; v2 (launched 2021) scans EC2, ECR images, and Lambda functions for CVEs automatically. Charges per scan target per month. Closest Azure analog: Defender for Cloud's vulnerability assessment.
HSM
Hardware Security Module. Tamper-resistant physical hardware that performs crypto operations and protects keys so they can never be exported. AWS KMS is multi-tenant HSM-backed (FIPS 140-2 Level 3 validated). CloudHSM is the single-tenant version - your own dedicated HSM cluster, billed by the hour, used when compliance demands "no shared hardware".
CMK / KMS key
Customer Master Key - the old name. AWS rebranded these to just "KMS key" in 2021 but every blog post still says CMK. They mean the same thing: a key you create and manage (as opposed to AWS-managed or AWS-owned keys).
Fun fact KMS keys live entirely inside FIPS 140-2 Level 3 validated HSMs. The raw key material never leaves the HSM - not over the API, not in a memory dump, not in a database row. You can only ask KMS to "use" the key on your behalf: encrypt this 4KB blob, decrypt that ciphertext, sign this hash. This is also why you can't migrate a KMS key out of AWS - by design, no software path can extract it. The trade-off is hard lock-in; the benefit is "we lost a server" never means "we lost the key".

KMS keys and envelope encryption

ELI5: what's a KMS key, really?
A KMS key is a tiny secret kept inside a tamper-proof box at AWS. You can't take it out. You hand AWS some data and say "encrypt this with key X" - AWS does it inside the box and hands you back ciphertext. To decrypt, you hand the ciphertext back and AWS unlocks it with the same key. Your code never sees the key. If your laptop is stolen, the thief can't decrypt anything without going through AWS's locked door first - which IAM can slam shut instantly.

Three flavors of KMS key

AWS-owned

Invisible to you. AWS uses these to encrypt data on your behalf for some services (e.g. SQS, default S3 encryption tier). You can't see them, audit them, or rotate them. Free.

Use when: you don't care, you just want at-rest encryption to be "on".

AWS-managed

AWS-created, in your account. Named like aws/s3, aws/rds. You can see them, audit them, but can't change the key policy or schedule deletion. Free. Auto-rotated annually.

Use when: you want auditability via CloudTrail but no special access controls.

Customer-managed (CMK)

You create, you control. Full key policy, IAM-driven access, optional rotation (off by default - opt in to 1-year auto-rotation). $1/month per key, plus API calls.

Use when: cross-account access, compliance, you need to revoke by disabling the key, or you want to control rotation.

Envelope encryption (the pattern that makes KMS scalable)

KMS itself has a strict request size limit: 4KB of plaintext per Encrypt call. You wouldn't shove a 5GB video file at it. Instead, services use envelope encryption: KMS encrypts a small data key, and the data key encrypts the bulk data locally.

Envelope encryption: KMS protects a small key, the small key protects your data Plaintext "big-video.mp4" 5 GB KMS GenerateDataKey with CMK "my-bucket-key" Data key (plaintext, 256-bit) Data key (encrypted) Local AES encrypt in your code / SDK on the EC2 host Stored in S3 ciphertext.bin (5 GB) + encrypted data key (metadata, ~200 bytes) 1. need a data key 2. mint both 3. use plaintext locally 4. data in 5. write stored alongside The plaintext data key is wiped from memory immediately after use. To decrypt later: send the encrypted data key back to KMS, get plaintext data key, AES-decrypt locally.
Why this pattern? KMS calls are slow and rate-limited. Local AES is fast and free. Envelope gets you "KMS-grade key protection" at "local AES speed". You'll see this same pattern in S3, EBS, RDS, Secrets Manager, and DynamoDB - all of them just use it under the hood.

Create a CMK side-by-side

Terraform · azurerm
resource "azurerm_key_vault" "app" {
  name                = "kv-app"
  location            = "eastus"
  resource_group_name = "rg-app"
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "premium"
}

resource "azurerm_key_vault_key" "app" {
  name         = "app-key"
  key_vault_id = azurerm_key_vault.app.id
  key_type     = "RSA-HSM"
  key_size     = 2048
  key_opts     = ["encrypt", "decrypt", "wrapKey", "unwrapKey"]
}
Terraform · aws
resource "aws_kms_key" "app" {
  description             = "Encrypts app data at rest"
  deletion_window_in_days = 14
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "EnableRootAdmin"
      Effect    = "Allow"
      Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.me.account_id}:root" }
      Action    = "kms:*"
      Resource  = "*"
    }]
  })
}

resource "aws_kms_alias" "app" {
  name          = "alias/app-key"
  target_key_id = aws_kms_key.app.key_id
}
Note the alias. A KMS key ID is an opaque UUID. An alias (alias/app-key) is the human-readable handle you reference in IaC, SDK calls, and bucket configs. Aliases can be re-pointed to a different key, which is the supported way to do "key migration" without touching every consumer.

Multi-region keys

By default, a KMS key lives in exactly one region. If you need the same key material in another region (cross-region replication of encrypted data, multi-region disaster recovery), you have two options:

PatternHowWhen to use
Different keys per regionDecrypt with regional key A, re-encrypt with regional key B when copying.The default. Cleaner blast radius - compromise of one region doesn't compromise the other.
Multi-region keysOne "primary" key + N "replica" keys that share the same key material across regions.Active-active workloads where you need to decrypt the same ciphertext in any region without re-encrypting. Common with global DynamoDB tables.
Bug hunt: the role is allowed, the key allows the role, why is decrypt denied?

A teammate has set up a Lambda function role with an identity policy that grants kms:* on the relevant key. The KMS key's key policy explicitly lists the Lambda role's ARN. The function still fails with AccessDeniedException on every kms:Decrypt. They show you both policies. What's wrong?

KMS key policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Root",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111:root"
      },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AllowLambdaRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111:role/gfn-reports-role"
      },
      "Action": "kms:Decrypt",
      "Resource": "*"
    }
  ]
}
gfn-reports-role identity policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    }
  ]
}
Click to reveal the bug
The role's identity policy doesn't grant kms:Decrypt on anything.

KMS is the well-known exception to "identity policy or resource policy is enough". For KMS, you need both: the key policy must allow the principal, and the principal's identity policy must allow the KMS action. This is "the AND requirement". Most other services follow OR (either side is enough for same-account); KMS demands AND.

Fix: add kms:Decrypt on the specific key ARN to the role's identity policy:

{
  "Effect": "Allow",
  "Action": ["kms:Decrypt", "kms:DescribeKey"],
  "Resource": "arn:aws:kms:us-east-1:111:key/abcd-..."
}

Pro tip: when troubleshooting KMS denies, always check both sides before suspecting CloudTrail lag or stale credentials. CloudTrail will log the denied action and even tell you "key policy denied" vs "identity policy denied" - read the errorMessage field.

Secrets Manager vs Parameter Store

ELI5: two secret stores, one platform
AWS has two places to put secrets. Parameter Store is like a notes app - cheap, fast, fine for "settings that happen to be sensitive". Secrets Manager is like a bank vault - it can rotate the secret automatically by calling a Lambda function every N days (so your DB password changes itself), but it charges rent ($0.40/month per secret). For app-level passwords/tokens that need rotation, use Secrets Manager. For config strings, feature flags, or "the API key for some external service that doesn't support rotation", use Parameter Store.
FeatureSecrets ManagerParameter Store (SecureString)
Storage cost$0.40 / secret / monthFree (standard tier, ≤ 4KB, ≤ 10,000 params)
API cost$0.05 / 10K callsFree (standard); $0.05 / 10K (advanced or throughput)
Max size64 KB4 KB (standard), 8 KB (advanced)
EncryptionKMS (AWS-managed or CMK)KMS for SecureString type; plaintext for String
Automatic rotationYes - native, via LambdaNo - you'd write your own scheduler
VersioningYes - AWSCURRENT / AWSPREVIOUS labelsYes - integer version numbers
Cross-account sharingYes - via resource policyYes - via resource policy (advanced tier)
Best forDB passwords, API keys that must rotate, anything with a published rotation Lambda templateConfig flags, build artifact versions, environment-specific strings, external API keys that can't rotate
Default answer: If your secret needs to rotate, use Secrets Manager. Otherwise, Parameter Store. Don't over-engineer - a team with 200 "secrets" in Secrets Manager spending $80/month plus rotation overhead, when most of them are immutable API keys, is paying for a feature they don't use.

Reading a secret from code

Azure · Key Vault
# Python - azure-keyvault-secrets
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

client = SecretClient(
    vault_url="https://kv-app.vault.azure.net",
    credential=DefaultAzureCredential(),
)
db_pwd = client.get_secret("db-password").value
AWS · Secrets Manager
# Python - boto3
import boto3, json

sm = boto3.client("secretsmanager")
resp = sm.get_secret_value(SecretId="prod/db/master")

# Convention: store as JSON blob
secret = json.loads(resp["SecretString"])
db_pwd = secret["password"]
db_user = secret["username"]
Convention worth adopting: store secrets as JSON blobs, not as flat strings. A "DB credential" is really {username, password, host, port, dbname} - one secret per logical credential, not five. Secrets Manager's rotation Lambdas (RDS templates etc.) require the JSON-blob shape; you fight the platform if you scatter related values across multiple secret IDs.

Secrets Manager rotation: what really happens

When you enable rotation on a secret, you schedule a Lambda. Every rotation interval (default 30 days), Secrets Manager invokes the Lambda four times with different step labels:

StepWhat the Lambda does
createSecretGenerate a new password. Store as AWSPENDING.
setSecretUpdate the underlying system (e.g. ALTER USER admin WITH PASSWORD ...) to accept the new password.
testSecretConnect using the new password. If it works, proceed. If not, fail and rollback.
finishSecretMove the AWSPENDING label to AWSCURRENT. Old version becomes AWSPREVIOUS.
Real incident Rotation broke production: the secret rotated, the app didn't notice $1.2M lost revenue

A fintech team enabled Secrets Manager rotation on their RDS master password. The rotation Lambda did its job - it called RDS's ModifyDBInstance with the new password, validated the connection, and flipped the AWSCURRENT label. Good.

The problem: the application didn't read from Secrets Manager. It read from Hashicorp Vault, which the team used everywhere else and had wired into their app months earlier. Vault still had the old password. When the next deploy restarted the app pods, they all pulled the stale password from Vault, failed every connection attempt with "auth failed", and started failing health checks. The load balancer pulled them out one by one.

By the time the on-call figured out what had happened (rotation was a four-day-old change nobody had paged on), there were no healthy pods. The four-hour outage hit during peak business hours and cost roughly $1.2M in lost transactions plus reputation damage.

Lessons: (1) Test rotation in a non-prod environment before flipping it on in prod. (2) Don't have two sources of truth for the same secret - pick one and route the others to it via a sync job. (3) Add an explicit alarm on "Secrets Manager rotation succeeded but application errors spiked" - that's the only correlation that catches this class of bug. (4) Run a tabletop "what depends on this secret?" exercise before enabling rotation.

Cost trap: rotation Lambda overhead at scale ~$1,500 / month

A platform team adopts Secrets Manager and rotates everything. They migrate 200 separate "secrets" (each one a single API key or DB cred) and enable native rotation on every one, set to a 30-day schedule. Each rotation Lambda lives in a VPC (so it can reach private RDS). Storage cost looks reasonable at $80/month for 200 secrets. Six weeks later, the bill is north of $1,500/month for the rotation infrastructure alone. Where's the money going?

Click to reveal the trap
It's a stack of small charges that compounds at scale.

1. Secrets storage: 200 × $0.40 = $80/month. Fixed baseline.

2. Lambda invocations: Every 30-day rotation triggers the Lambda 4 times (createSecret, setSecret, testSecret, finishSecret). 200 × 4 = 800 invocations/month. Lambda itself is cheap, but...

3. VPC-attached Lambda ENI lifecycle: each rotation Lambda needs an ENI in the VPC to reach RDS. Cold-start ENI attach is ~30s, and the ENIs hang around briefly. With 200 Lambdas firing at semi-random times, ENI churn adds up: $0.01/hr per active ENI, NAT GW data charges if the Lambda reaches out to the public KMS endpoint (instead of a VPC endpoint), and CloudWatch Logs ingestion ($0.50/GB).

4. KMS calls: every get_secret_value from the app and every rotation step triggers kms:Decrypt. At 200 secrets × ~1000 app reads/day × $0.03/10K, that's another $20-50/month.

5. Secrets Manager GetSecretValue API: $0.05 per 10K. Apps that hit it on every request (instead of caching) burn through this fast - one heavy service caused $400/month of API calls alone.

Fix: bundle related secrets into a single JSON-blob secret (DB creds = one secret with username/password/host/port/db inside). Drop from 200 secrets to ~30. Share one rotation Lambda across many secrets via a single function with switch-on-secret-name logic. Add VPC endpoints for Secrets Manager + KMS so traffic doesn't cross the NAT gateway. Cache secrets in-memory for the lifetime of the process (or 5 min, whichever is shorter), and re-fetch on auth failure. End result: bill drops to ~$120/month.

The general lesson: Secrets Manager's price model rewards fewer, larger secrets and frugal callers. Parameter Store rewards lots of small free reads. Match the tool to the access pattern.

ACM: free TLS certs (mostly)

ELI5: what ACM gives you
In Azure you might use App Service managed certificates or upload your own to Key Vault and attach them to Front Door. In AWS, ACM hands you free public TLS certificates for any AWS service that terminates TLS - ALBs, CloudFront, API Gateway, AppSync. You prove you own the domain (DNS or email) once, and ACM auto-renews the cert every year forever. If you need private internal CA certs (microservices mTLS), that's "ACM Private CA" and it's expensive: minimum $400/month for the CA itself, plus per-cert charges.

Public vs private CA

ACM PublicACM Private CA
CostFree for the cert; you pay only for the resource serving it (ALB, etc.)$400/month per CA + $0.75/cert/month tiered down
TrustBrowsers/clients trust ACM's roots via WebPKIOnly clients you give the root CA to
Use casePublic-facing HTTPS (ALB, CloudFront, custom domain on API GW)Internal mTLS (service mesh, K8s ingress mTLS, IoT device certs)
Where the cert livesACM only; private key never exportableACM (with optional export for private CA, paid extra)
ValidationDNS (auto-renew works) or email (auto-renew breaks)Your CA, your rules

Get a free public TLS cert (Terraform)

acm.tf
resource "aws_acm_certificate" "app" {
  domain_name               = "app.example.com"
  subject_alternative_names = ["www.app.example.com"]
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

# Wire up the DNS CNAMEs ACM asks for, in Route 53
resource "aws_route53_record" "validation" {
  for_each = {
    for dvo in aws_acm_certificate.app.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = data.aws_route53_zone.app.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "app" {
  certificate_arn         = aws_acm_certificate.app.arn
  validation_record_fqdns = [for r in aws_route53_record.validation : r.fqdn]
}
Auto-renewal trigger: ACM begins attempting renewal 60 days before expiry. If you chose DNS validation and the validation CNAMEs are still in your zone, renewal is silent and automatic - you'll never notice. If you chose email validation, renewal requires a human to click an email link - and the cert will expire if nobody does. Choose DNS validation. Always.
ACM-issued certs cannot be exported. The private key never leaves ACM. This means you can only attach the cert to AWS-managed TLS terminators (ALB, NLB, CloudFront, API GW, AppSync, etc.). If you need a cert for an EC2 nginx running TLS itself, you cannot use a public ACM cert - you'd need ACM Private CA (which allows export, paid feature), or you'd issue from Let's Encrypt or your own CA. This is a frequent surprise; plan terminator placement accordingly.

GuardDuty: managed threat detection

ELI5: what does GuardDuty actually do?
GuardDuty is an "always-on burglar alarm" for your AWS account. You don't ship it logs - it has hooks already into CloudTrail, VPC Flow Logs, and Route 53 DNS logs at the platform level. It pattern-matches against AWS's threat-intel feeds and ML-detected anomalies and emits findings like "instance i-123 is talking to a known Bitcoin mining pool" or "your access key was used from Mongolia for the first time ever". You then triage the findings the same way you'd triage Defender alerts in Azure.

What GuardDuty watches

SourceWhat it catches
CloudTrail mgmt eventsUnusual IAM behavior (root login, key creation, policy escalation), API calls from unusual countries
CloudTrail data events (S3, EKS, Lambda)Anomalous data access. This is the expensive one - data events are high-volume and GuardDuty charges per event analyzed.
VPC Flow LogsConnections to known-bad IPs (C2 servers, Tor exit nodes), unusual port scans
Route 53 DNS logsDNS resolutions to malware-hosting domains, DGA (domain generation algorithm) patterns
EKS audit logsAnomalous control-plane activity in Kubernetes
EBS volume malware scanOptional add-on - scans EBS volumes attached to EC2 for known malware signatures
The cost driver is data-event volume. Enabling GuardDuty mgmt-event analysis is cheap (cents per million CloudTrail events). Enabling S3 data-event protection on a heavily-used data lake can cost thousands of dollars per month. Check the Cost Explorer "GuardDuty" service breakdown after a week before assuming defaults are sane.

Findings, in practice

A GuardDuty finding looks like this (truncated):

finding.json
{
  "SchemaVersion": "2.0",
  "Type": "CryptoCurrency:EC2/BitcoinTool.B!DNS",
  "Severity": 8.0,
  "Title": "EC2 instance is querying a domain associated with a Bitcoin-related activity.",
  "Description": "EC2 instance i-0abc123 is querying a domain on a known Bitcoin denylist.",
  "Resource": {
    "ResourceType": "Instance",
    "InstanceDetails": {
      "InstanceId": "i-0abc123def4567890",
      "InstanceType": "t3.micro",
      "LaunchTime": "2025-03-12T18:21:00Z"
    }
  },
  "Service": {
    "Action": { "ActionType": "DNS_REQUEST" },
    "Count": 42,
    "EventFirstSeen": "2025-03-13T09:14:00Z"
  }
}

Findings stream into EventBridge by default. Wire them up to SNS, Slack, PagerDuty, or your SIEM. Severity is on a 1-10 scale; teams typically alert on >=7 (high) and dashboard everything below.

Multi-account pattern: in an AWS Organization, designate one "security" account as the GuardDuty delegated administrator. Member accounts auto-enroll, and findings aggregate into the security account. Same pattern works for Security Hub, Macie, Inspector. Don't run security tooling per-account; run it once at the org level. We cover the org wiring in chapter 12.

Security Hub & Access Analyzer

Security Hub: the aggregator

Security Hub is AWS's CSPM and findings-aggregation service. It does two things:

  1. Pulls findings from sister services - GuardDuty, Inspector, Macie, IAM Access Analyzer, AWS Config, Firewall Manager, plus third-party (Crowdstrike, Splunk, etc.) - into a single normalized format (ASFF: AWS Security Finding Format).
  2. Runs continuous control checks against benchmarks (AWS Foundational Security Best Practices, CIS, PCI-DSS, NIST 800-53). Outputs pass/fail per control per resource.
Security Hub: one pane of glass for many sources GuardDuty Inspector Macie Access Analyzer AWS Config Firewall Mgr Partners (Crowdstrike, Splunk) Security Hub ASFF normalization + CIS/AWS FSBP checks + compliance score EventBridge Slack / Pager SIEM (Splunk) JIRA tickets
Security Hub is the funnel. It doesn't generate findings by itself (except CIS/FSBP control checks) - it consolidates them. Pair it with EventBridge to fan out to humans and trackers.

IAM Access Analyzer: catch the over-shares

The single most useful free security tool in AWS. Access Analyzer continuously inspects resource-based policies (S3 buckets, KMS keys, IAM roles, Lambda functions, SQS, Secrets Manager, etc.) and flags anything that grants access to:

It's free to enable. There is no excuse to not enable it. It catches "we accidentally made the S3 bucket public" 99% of the time, often within minutes of the misconfiguration.

terminal · enable Access Analyzer for your account
aws accessanalyzer create-analyzer \
  --analyzer-name account-analyzer \
  --type ACCOUNT

# For org-wide visibility (run in mgmt account):
aws accessanalyzer create-analyzer \
  --analyzer-name org-analyzer \
  --type ORGANIZATION

# Findings show up immediately:
aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:111:analyzer/account-analyzer \
  --query "findings[?status=='ACTIVE'].[id,resource,resourceType]" \
  --output table
Closest Azure analog: there isn't a clean one. Defender for Cloud's CSPM picks up some of it (publicly accessible storage, etc.) but it isn't policy-graph-driven the way Access Analyzer is. Access Analyzer uses Zelkova - a formal-methods reasoning engine that proves whether a policy grants access, rather than pattern-matching. That's why it has near-zero false positives.

Try it: peek at your security posture

Lab: read-only tour of KMS, Secrets Manager, ACM, GuardDuty $0

Goal: get oriented in your account's existing security primitives before adding new ones. These are all read-only commands - nothing gets created. If your account has been used for any AWS service, you almost certainly have at least one AWS-managed key already.

Step 1. List all KMS keys in the current region.

terminal
aws kms list-keys --region us-east-1

# Get details on one to see its kind (AWS_OWNED, AWS_MANAGED, CUSTOMER):
aws kms describe-key --key-id <some-key-id-from-above>

# Or list with aliases, which is more readable:
aws kms list-aliases --query "Aliases[].[AliasName,TargetKeyId]" --output table

Step 2. See what secrets exist (likely empty if you've never used Secrets Manager).

terminal
aws secretsmanager list-secrets \
  --query "SecretList[].[Name,LastChangedDate,RotationEnabled]" \
  --output table

# For a specific secret, see metadata WITHOUT revealing the value:
aws secretsmanager describe-secret --secret-id <name>

# To actually fetch the plaintext (this is the dangerous one):
# aws secretsmanager get-secret-value --secret-id <name>

Step 3. List ACM certificates and their statuses.

terminal
aws acm list-certificates --query "CertificateSummaryList[].[DomainName,Status,CertificateArn]" --output table

# Get details including renewal status:
aws acm describe-certificate --certificate-arn <arn> \
  --query "Certificate.[DomainName,Status,NotAfter,RenewalSummary.RenewalStatus]"

Step 4. Check whether GuardDuty is on. Most production accounts have it; many sandbox accounts don't.

terminal
aws guardduty list-detectors

# If non-empty, get current findings count by severity:
DETECTOR=$(aws guardduty list-detectors --query "DetectorIds[0]" --output text)
aws guardduty get-findings-statistics \
  --detector-id $DETECTOR \
  --finding-statistic-types COUNT_BY_SEVERITY

# Last 10 highest-severity findings:
aws guardduty list-findings --detector-id $DETECTOR \
  --finding-criteria '{"Criterion":{"severity":{"Gte":7}}}' \
  --max-results 10

Step 5. Check IAM Access Analyzer findings (the goldmine).

terminal
aws accessanalyzer list-analyzers

# If you have one, list active findings - these are real "over-shared resource" problems:
ANALYZER=$(aws accessanalyzer list-analyzers --query "analyzers[0].arn" --output text)
aws accessanalyzer list-findings \
  --analyzer-arn $ANALYZER \
  --filter '{"status":{"eq":["ACTIVE"]}}' \
  --max-results 25

What you learned: every security primitive in AWS is just an API. There is no "Security Center" dashboard you must use - the data is all queryable. This is how senior engineers carry a few aliases / shell functions and never click into the console for security review.

Quick check

Test yourself - 5 questions

Security is the chapter where assumptions cost money. Sit with each one before revealing.

1. By default, KMS rotates which keys, and on what schedule?

  • All keys (AWS-owned, AWS-managed, customer-managed) rotate every 90 days automatically.
  • Only AWS-managed keys rotate automatically (every ~1 year). Customer-managed keys do not rotate unless you opt in.
  • Customer-managed keys rotate every 30 days by default; AWS-managed keys never rotate.
  • No KMS keys rotate without an explicit Lambda you wire up.
Show answer
Answer: b. AWS-managed keys (the aws/* aliases) auto-rotate roughly every 1 year - you can't opt out, you can't opt in to a different schedule. Customer-managed keys (CMKs) do not rotate by default; you set enable_key_rotation = true in Terraform (or check the box in the console) to get annual automatic rotation. AWS-owned keys are invisible and rotate on AWS's own schedule. The 90-day option is for "manual rotation" via re-encrypt, not native rotation.

2. You enable Secrets Manager rotation on an RDS Postgres password. What's the minimum piece you have to provide?

  • Nothing - it just works because Secrets Manager has built-in RDS rotation.
  • A Lambda function (either a stock AWS template or your own) that knows how to rotate the credential, plus an IAM role on the function.
  • A second Secrets Manager secret to hold the previous password.
  • An ACM certificate for the rotation API.
Show answer
Answer: b. Rotation is always a Lambda function under the hood. AWS publishes templates for common rotators (RDS MySQL/Postgres/Aurora, Redshift, DocumentDB), so for these you don't write code - but you do still deploy the function (the Serverless Application Repository has 1-click installers). For non-AWS systems (Snowflake, Hashicorp Vault, third-party APIs), you write the Lambda yourself implementing the 4 steps: createSecret, setSecret, testSecret, finishSecret.

3. An ACM-issued public certificate is attached to an ALB. The cert expires in 65 days. What happens?

  • Nothing yet - ACM begins renewal automatically 60 days before expiry.
  • The ALB starts returning TLS errors as soon as we cross 60 days.
  • You'll get a billing alert at 30 days.
  • You must manually re-request the cert via the console.
Show answer
Answer: a. ACM begins auto-renewal 60 days before expiry. With DNS validation, this is silent - ACM proves domain ownership via the validation CNAMEs you set up at issuance, and the cert is renewed with no human action. With email validation, AWS sends an email to the registered domain admin, who must click a link; if nobody clicks, the cert is not renewed and will expire. This is the single best reason to always choose DNS validation when issuing.

4. Which of the following is the biggest cost driver in a GuardDuty bill?

  • The number of EC2 instances in the account.
  • The number of accounts under the org's delegated admin.
  • The volume of CloudTrail data events analyzed - particularly S3 data events on high-throughput buckets.
  • The number of findings generated per month.
Show answer
Answer: c. GuardDuty pricing is "per GB of data analyzed", and the volume of data events dwarfs everything else. S3 GetObject/PutObject events on a busy data lake can easily produce billions of CloudTrail data events per month - GuardDuty's S3 Protection then charges for analyzing each. Mgmt events are cheap, VPC Flow Logs are mid-cost. Always check the per-feature breakdown after a week of enablement and consider scoping S3 Protection to specific high-value buckets via finding filters rather than enabling globally.

5. You create a Parameter Store parameter of type SecureString. Which statement is true about how the value is protected?

  • SecureString is encrypted with the default AWS-managed KMS key (aws/ssm) unless you specify a CMK; reads still require both Parameter Store and KMS permissions on the principal.
  • SecureString uses a separate proprietary encryption that doesn't touch KMS.
  • SecureString values are returned in plaintext only inside a VPC.
  • SecureString is the same as String but the value is base64-encoded.
Show answer
Answer: a. SecureString in Parameter Store is encrypted at rest with KMS - either the AWS-managed aws/ssm key (default, free) or a CMK you specify. To read a SecureString and get plaintext back, the caller needs both ssm:GetParameter (or GetParameters) with WithDecryption=true, AND kms:Decrypt on the underlying key. This is the same "two-side" requirement we saw for KMS in chapter 2's quiz: the resource (SSM) and the key (KMS) both have to allow the principal.

Gotchas for Azure devs

1. KMS customer-managed keys cost $1/month each, every month, forever. Trivial per key. Adds up fast when "least privilege" enthusiasts create one CMK per microservice. Azure Key Vault Premium is similar in spirit, but the per-resource billing model on AWS is more granular. Audit your CMK count quarterly - and prefer AWS-managed keys when an audit-only requirement is the driver.
2. Secrets Manager is $0.40 per secret per month plus API call charges. 200 secrets is $80/month before any reads. App that reads on every request without caching can easily add $200-500/month in GetSecretValue calls. Cache aggressively (5-15 min in-memory), and store related credentials as one JSON-blob secret rather than several individual entries.
3. ACM Private CA has a $400/month minimum, per CA. The Private CA service starts billing the moment you create the CA, whether or not you've issued certs. If you stand up Private CA "to evaluate", remember to delete (not just disable) it within the month. Many teams have eaten $400-$1200 from a Private CA forgotten in a sandbox account.
4. GuardDuty's S3 data-event protection is expensive on busy buckets. The default is to analyze all CloudTrail data events from all S3 buckets. On a high-throughput data lake, this can be thousands of dollars per month. Either scope S3 Protection to only the buckets that matter (via the GuardDuty console / API), or accept the cost and budget for it - do not learn this from the bill.
5. KMS key deletion has a 7-30 day waiting period and is irreversible. When you "delete" a KMS key, it goes into a pending deletion state for the window you specify (default 30 days, minimum 7). During the window the key is unusable - any service trying to decrypt with it will fail. After the window, the key material is destroyed and any data encrypted with it is permanently unreadable. Worse: there is no recovery once material is destroyed - not even AWS Support can recover it. Always schedule deletion with a long window, and double-check no data is still encrypted under the key first (audit CloudTrail for recent uses).
6. The KMS "AND" rule isn't optional. For most AWS services, an Allow in either the identity policy or the resource policy is sufficient for same-account requests. KMS demands both: the key policy must allow the principal and the principal's identity policy must allow the KMS action. This single rule is responsible for a disproportionate fraction of "I have admin, why is decrypt denied?" tickets.

Project Compass: KMS + Secrets

Picking up from chapter 7, where gfn-reports got an SQS intake queue. So far the raw S3 bucket from chapter 5 uses default (AWS-managed) encryption, and the role has no access to any secrets. In this slice we tighten encryption with a CMK and prepare to consume an external API key via Secrets Manager.

Project Compass · Step 8 of 12 Add a CMK and a managed secret to gfn-reports

Why now: gfn-reports will soon call an external "ranking" API that needs an API key. We won't bake the key into env vars; we'll store it in Secrets Manager. While we're here, we'll promote raw-bucket encryption from AWS-managed to a customer-managed key for explicit control + audit.

This chapter's slice (Terraform)
compass/security.tf
# --- Customer-managed key for gfn-reports project ---
data "aws_caller_identity" "me" {}

resource "aws_kms_key" "gfn_reports" {
  description             = "CMK for gfn-reports raw bucket + future secrets"
  deletion_window_in_days = 14
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "RootCanAdmin"
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.me.account_id}:root" }
        Action    = "kms:*"
        Resource  = "*"
      },
      {
        Sid       = "AllowGfnReportsRole"
        Effect    = "Allow"
        Principal = { AWS = aws_iam_role.gfn_reports.arn }
        Action    = [
          "kms:Decrypt",
          "kms:GenerateDataKey",
          "kms:DescribeKey"
        ]
        Resource  = "*"
      }
    ]
  })

  tags = {
    Project   = "Compass"
    ManagedBy = "learn-aws"
  }
}

resource "aws_kms_alias" "gfn_reports" {
  name          = "alias/gfn-reports"
  target_key_id = aws_kms_key.gfn_reports.key_id
}
compass/s3-encryption.tf · attach CMK to the raw bucket
# Updates the chapter-5 bucket to use the new CMK + Bucket Keys
# (Bucket Keys reduce KMS API calls by ~99%, see Ch.5 cost trap)
resource "aws_s3_bucket_server_side_encryption_configuration" "raw" {
  bucket = aws_s3_bucket.gfn_reports_raw.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.gfn_reports.arn
    }
    bucket_key_enabled = true
  }
}
compass/secrets.tf · external ranking API key
resource "aws_secretsmanager_secret" "ranking_api" {
  name        = "compass/gfn-reports/ranking-api"
  description = "API key for external ranking provider"
  kms_key_id  = aws_kms_key.gfn_reports.arn

  tags = {
    Project   = "Compass"
    ManagedBy = "learn-aws"
  }
}

resource "aws_secretsmanager_secret_version" "ranking_api_v1" {
  secret_id = aws_secretsmanager_secret.ranking_api.id
  secret_string = jsonencode({
    # Placeholder - in practice, set via a CI secret or aws cli, not committed.
    api_key  = "PLACEHOLDER_ROTATE_BEFORE_USE"
    endpoint = "https://ranking.example.internal/v1/score"
  })

  lifecycle {
    # Terraform should not overwrite the value once set out-of-band
    ignore_changes = [secret_string]
  }
}
compass/iam.tf · extend gfn-reports-role
# Add to the existing role's inline policy
resource "aws_iam_role_policy" "secrets_and_kms" {
  name = "compass-secrets-kms"
  role = aws_iam_role.gfn_reports.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DecryptWithProjectCMK"
        Effect = "Allow"
        Action = [
          "kms:Decrypt",
          "kms:GenerateDataKey",
          "kms:DescribeKey"
        ]
        Resource = aws_kms_key.gfn_reports.arn
      },
      {
        Sid    = "ReadRankingApiSecret"
        Effect = "Allow"
        Action = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
        Resource = aws_secretsmanager_secret.ranking_api.arn
      }
    ]
  })
}

Note the symmetry that satisfies the KMS AND-rule: the key's policy lists aws_iam_role.gfn_reports as a Principal, and the role's identity policy grants kms:Decrypt on the key ARN. Both sides agree. The secret is scoped to this one ARN rather than arn:aws:secretsmanager:*, so a future leak of the role can't read other teams' secrets.

Apply and verify
terminal
cd compass/
terraform plan
terraform apply

# Verify the key works end-to-end:
aws kms describe-key --key-id alias/gfn-reports --profile compass

# Verify the bucket is now using the CMK:
aws s3api get-bucket-encryption \
  --bucket gfn-reports-raw-${ACCOUNT_ID} --profile compass

# Verify the role can read the secret (impersonate it):
aws sts assume-role \
  --role-arn arn:aws:iam::${ACCOUNT_ID}:role/gfn-reports-role \
  --role-session-name secret-test \
  --profile compass | jq -r .Credentials > /tmp/creds.json
# (export the creds and try GetSecretValue)
Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Reminder for chapter 12: right now Compass has Terraform sprawl across multiple .tf files in one directory. In chapter 12 we'll refactor into modules and a multi-account layout. Don't tidy up now - the mess is the point of the chapter-12 refactor.

Recap & next

What stuck?

The mental model in one sentence

Azure leans on platform-managed encryption with one Key Vault. AWS gives you a key API (KMS), a secrets API (Secrets Manager / Parameter Store), and a posture API (Security Hub / GuardDuty / Access Analyzer) - all separate, all queryable, all priced individually, all opt-in to the policies that connect them.

Common pitfalls so far

TrapFix
"My role has kms:* but decrypt is denied"The KMS key's resource policy probably doesn't list the role. Check both sides.
"Secrets Manager bill is huge"Audit secret count and read patterns. Bundle related values, cache reads, use VPC endpoints.
"Why didn't ACM renew my cert?"You used email validation. Re-issue with DNS validation and the renewal becomes silent and automatic.
"GuardDuty bill is huge"S3 Protection is on for a heavy bucket. Scope it down or accept it.
"I accidentally deleted a KMS key"Within 7-30 days: cancel deletion (aws kms cancel-key-deletion). After: data is gone. Always use long windows and audit usage first.
NEXT CHAPTER
9. Observability
CloudWatch logs/metrics/alarms vs Log Analytics, CloudTrail vs Activity Log, X-Ray vs App Insights - and why Logs Insights is not KQL.