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.
| Concept | Azure | AWS |
|---|---|---|
| Key vault for crypto keys | Key Vault (keys) | KMS (Key Management Service) |
| Secret store | Key Vault (secrets) | Secrets Manager or SSM Parameter Store (SecureString) |
| App-managed certificates | App Service managed certs, Key Vault certs | ACM (AWS Certificate Manager) - free for public, paid for private CA |
| Hardware-backed keys | Key Vault Premium / Managed HSM | KMS (HSM-backed by default) + CloudHSM (single-tenant) |
| Workload threat detection | Defender for Cloud (CWPP) | GuardDuty |
| Posture / compliance aggregation | Defender for Cloud (CSPM) / Secure Score | Security Hub |
| SIEM | Microsoft Sentinel | Security Hub + Detective (or third-party: Splunk, Datadog) |
| Data classification | Microsoft Purview / Information Protection | Macie (S3-focused PII discovery) |
| Web application firewall | Front Door WAF / App Gateway WAF | AWS WAF (attached to ALB, CloudFront, API GW, AppSync) |
| DDoS protection | DDoS Protection Standard | Shield Standard (free) / Shield Advanced (paid) |
| Private connectivity | Private Link / Private Endpoint | PrivateLink / VPC Endpoints (interface & gateway) |
| Find over-shared resources | No clean single tool; relies on Defender | IAM Access Analyzer |
| Audit trail | Activity Log | CloudTrail (we cover in chapter 9) |
| Default encryption at rest | On by default everywhere (platform-managed keys) | On for most services, but historically opt-in per resource. Defaults have tightened over time. |
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-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.
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.
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.
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"]
}
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
}
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.
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:
| Pattern | How | When to use |
|---|---|---|
| Different keys per region | Decrypt 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 keys | One "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. |
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?
{
"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": "*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
}
]
}
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.
| Feature | Secrets Manager | Parameter Store (SecureString) |
|---|---|---|
| Storage cost | $0.40 / secret / month | Free (standard tier, ≤ 4KB, ≤ 10,000 params) |
| API cost | $0.05 / 10K calls | Free (standard); $0.05 / 10K (advanced or throughput) |
| Max size | 64 KB | 4 KB (standard), 8 KB (advanced) |
| Encryption | KMS (AWS-managed or CMK) | KMS for SecureString type; plaintext for String |
| Automatic rotation | Yes - native, via Lambda | No - you'd write your own scheduler |
| Versioning | Yes - AWSCURRENT / AWSPREVIOUS labels | Yes - integer version numbers |
| Cross-account sharing | Yes - via resource policy | Yes - via resource policy (advanced tier) |
| Best for | DB passwords, API keys that must rotate, anything with a published rotation Lambda template | Config flags, build artifact versions, environment-specific strings, external API keys that can't rotate |
# 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
# 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"]
{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.
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:
| Step | What the Lambda does |
|---|---|
createSecret | Generate a new password. Store as AWSPENDING. |
setSecret | Update the underlying system (e.g. ALTER USER admin WITH PASSWORD ...) to accept the new password. |
testSecret | Connect using the new password. If it works, proceed. If not, fail and rollback. |
finishSecret | Move the AWSPENDING label to AWSCURRENT. Old version becomes AWSPREVIOUS. |
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.
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?
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 Public | ACM Private CA | |
|---|---|---|
| Cost | Free for the cert; you pay only for the resource serving it (ALB, etc.) | $400/month per CA + $0.75/cert/month tiered down |
| Trust | Browsers/clients trust ACM's roots via WebPKI | Only clients you give the root CA to |
| Use case | Public-facing HTTPS (ALB, CloudFront, custom domain on API GW) | Internal mTLS (service mesh, K8s ingress mTLS, IoT device certs) |
| Where the cert lives | ACM only; private key never exportable | ACM (with optional export for private CA, paid extra) |
| Validation | DNS (auto-renew works) or email (auto-renew breaks) | Your CA, your rules |
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]
}
| Source | What it catches |
|---|---|
| CloudTrail mgmt events | Unusual 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 Logs | Connections to known-bad IPs (C2 servers, Tor exit nodes), unusual port scans |
| Route 53 DNS logs | DNS resolutions to malware-hosting domains, DGA (domain generation algorithm) patterns |
| EKS audit logs | Anomalous control-plane activity in Kubernetes |
| EBS volume malware scan | Optional add-on - scans EBS volumes attached to EC2 for known malware signatures |
A GuardDuty finding looks like this (truncated):
{
"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.
Security Hub is AWS's CSPM and findings-aggregation service. It does two things:
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:
Principal: * with no condition narrowing it)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.
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
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.
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).
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.
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.
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).
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.
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?
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?
3. An ACM-issued public certificate is attached to an ALB. The cert expires in 65 days. What happens?
4. Which of the following is the biggest cost driver in a GuardDuty bill?
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?
aws/ssm) unless you specify a CMK; reads still require both Parameter Store and KMS permissions on the principal.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.
GetSecretValue calls. Cache aggressively (5-15 min in-memory), and store related credentials as one JSON-blob secret rather than several individual entries.
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.
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.
# --- 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
}
# 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
}
}
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]
}
}
# 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.
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)
.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.
| Trap | Fix |
|---|---|
"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. |