Encryption and Secrets

AWS Intermediate/Advanced

Chapter 3 ยท KMS, Secrets Manager and Parameter Store

Almost everything you store in AWS is encrypted, and most of the time you never see it happen. This chapter is about the times you do see it: when a policy has to say who may use a key, when a key is rotated or scheduled for deletion, when an application needs a database password and you must decide where it lives, and when an auditor asks who used what. It covers AWS KMS (the key service), the defaults for S3, EBS and RDS, envelope encryption (the pattern that makes encrypting big data with a small key service possible), Secrets Manager, Parameter Store, certificates, and the CloudTrail records that prove what happened.

How every example in this chapter was verified
There is no AWS account and no AWS CLI on the machine that wrote this course, so nothing here was run against AWS.
  • Run here (level 1): Python 3.14.0 on Windows 11, in a scratch folder outside this site, with cryptography 50.0.2 (AES-GCM), the aws-encryption-sdk 4.0.7 library, boto3/botocore 1.43.112 (offline, through its test Stubber) and parliament 1.6.4 (an offline policy linter). Output is pasted from the real runs. Docker is installed but its engine was not running, so no container example was run.
  • Needs an AWS account (level 2): none in this chapter. Nothing here creates, changes or costs anything.
  • From the documentation, not run (level 3): everything about how AWS KMS, Secrets Manager, Parameter Store, ACM and CloudTrail behave. I read their pages and the pricing pages on 2026-10-12 through a page-fetch tool that summarises pages, so quotes are as that tool returned them. AWS changes limits and prices often; check the current page before relying on a number.
Three things here are models or stand-ins, not AWS: LocalKms (a teaching stand-in for the key service), the policy evaluator from AWS Intermediate/Advanced 2 applied to key policies, and a small model of the Secrets Manager rotation protocol. Each says what it copies from the documentation and what it does not.

Encryption at Rest: What AWS Does Before You Ask

The defaults differ by service, and the differences matter when you are asked "is it encrypted?":

ServiceDefaultWhat you can and cannot change (from the documentation)
Amazon S3"Starting January 5, 2023, all new object uploads to Amazon S3 are automatically encrypted at no additional cost" with SSE-S3 (AES-256)You cannot disable encryption for new uploads. Objects uploaded before that date in an unencrypted bucket are not encrypted automatically: use S3 Batch Operations to make encrypted copies. CloudTrail records "SSEApplied":"Default_SSE_S3"
Amazon EBSNot on by default: "encryption by default" is a Region-specific account setting. When enabled, new volumes and snapshot copies are encrypted"Encryption by default has no effect on existing EBS volumes or snapshots." It cannot be disabled for individual volumes in that Region. The default key is the AWS managed key aws/ebs, which you can change to a customer managed key
Amazon RDSYou choose at creation (--storage-encrypted)"You can only encrypt an Amazon RDS DB instance when you create it, not after." You cannot turn it off; you cannot change the KMS key of an existing encrypted instance. The workaround: snapshot, encrypted copy, restore (the page also mentions blue/green deployments). Logs, backups, snapshots and read replicas are encrypted too

One RDS detail shows why key management is an availability matter, not only a security one. If the KMS key becomes unusable (for example it is disabled), the page says an encrypted instance with backups enabled "goes into the inaccessible-encryption-credentials-recoverable state 2 hours after detection", stays stopped for seven days while you can re-enable the key and restart it, and after that is no longer usable and can only be restored from a snapshot or backup.

KMS Keys

A KMS key is a logical key in the AWS Key Management Service; its secret material is kept inside AWS's hardware security modules and is "designed never to be exported from the HSM in plaintext". You ask the service to use it; you do not get it. There are three kinds, and the documentation's comparison is the one to remember:

Customer managed keyAWS managed keyAWS owned key
Who controls the key policyYou, exclusivelyThe service; you can view itThe AWS service only; you cannot even view it
In your account?YesYes, alias form aws/<service> (e.g. aws/ebs)No (a service account)
RotationOptional; you set itRequired, every year (about 365 days); you cannot change itThe service decides
Audit in CloudTrailYesYesNot viewable by you
Cost (as read 2026-10-12)$1 per key per month (pro-rated hourly), plus requestsNo monthly fee; per-use fee (some services pay it for you)None
Sharing across accountsYes, through the key policy (below)No: "You cannot share resources encrypted under an AWS managed key with other accounts"Easy cross-account sharing

The documentation's advice is "use customer managed keys when control is important, but use AWS owned keys when convenience is most important". It also says AWS managed keys are "a legacy key type that is no longer being created for new AWS services as of 2021". Prices from the KMS pricing page: $1 per month per key, $0.03 per 10,000 requests, a free tier of 20,000 requests per month across all Regions (asymmetric operations are excluded from it), and $1 per month extra for the first and second rotation of key material (later rotations are not billed). The page named no Region for the request price. You refer to a key by key ID, key ARN, alias name (alias/Name) or alias ARN; to use a key in another account you must use the key ARN or alias ARN.

Envelope Encryption

KMS will encrypt at most 4,096 bytes directly ("Encrypts plaintext of up to 4,096 bytes"). That is enough for a password and not enough for a file. The standard answer is envelope encryption: ask KMS for a data key (GenerateDataKey returns a plaintext copy and a copy encrypted under your KMS key), encrypt the big data locally with the plaintext key, throw the plaintext key away, and store the encrypted data key next to the data. To read it back, ask KMS to decrypt the stored data key, then decrypt locally. The key service sees only a 32-byte key per object; the bulk bytes never leave your process.

I built this by hand with a local stand-in for KMS (LocalKms: it keeps keys in memory and copies the documented behaviours listed at the top of the file, such as the 4,096-byte limit, the key and key version recorded inside the ciphertext blob, the encryption context as authenticated data, and rotation that adds key material without touching data). It is not KMS. What is real is the AES-256-GCM encryption, the sizes and the failures. The core:

def envelope_encrypt(kms, kid, data, ctx): dk, wrapped = kms.generate_data_key(kid, ctx) nonce = os.urandom(12) ct = AESGCM(dk).encrypt(nonce, data, canon(ctx)) return {"wrapped_key": wrapped, "nonce": nonce, "ciphertext": ct, "ctx": ctx} def envelope_decrypt(kms, obj, ctx): dk = kms.decrypt(obj["wrapped_key"], ctx) return AESGCM(dk).decrypt(obj["nonce"], obj["ciphertext"], canon(ctx))

Run here (level 1).

py -X utf8 envelope.py
1. direct encryption has a small limit (4,096 bytes, as documented for KMS) encrypt 4,096 bytes directly -> ok (4136) encrypt 4,097 bytes directly -> KmsError: ValidationException: Plaintext is 4097 bytes; the limit is 4096 2. envelope encryption of 50 MB: only the 32-byte data key goes through the key service plaintext 52,428,800 bytes; ciphertext 52,428,816 bytes (+16); nonce 12; wrapped data key 72 bytes round trip identical: True; encrypt 0.01 s, decrypt 0.01 s on this machine (single run) 3. the encryption context is bound to the data decrypt with the same context -> ok (invoice 4411: total 1,250.00) decrypt with tenant t-18 instead of t-17 -> KmsError: InvalidCiphertextException: wrong encryption context or damaged blob decrypt with no context at all -> KmsError: InvalidCiphertextException: wrong encryption context or damaged blob decrypt after flipping ONE bit of the ciphertext -> InvalidTag: decrypt after swapping in another object's wrapped data key -> InvalidTag: 4. rotation changes the KEY MATERIAL, not your data (the Rotate keys page) wrapped key made with key version 1; rotating the key ... key now has 2 versions decrypt the OLD object after rotation -> ok (invoice 4411: total 1,250.00) a NEW object is wrapped with key version 2 re-encrypting only the wrapped data key moves it to version 2; bulk ciphertext hash before 7bcb8a4fe46a after 7bcb8a4fe46a (unchanged) decrypt the object after the wrapped key was moved -> ok (invoice 4411: total 1,250.00) decrypt an object still wrapped under the DESTROYED version 1 -> KmsError: InvalidCiphertextException: wrong encryption context or damaged blob decrypt the object that had been moved to version 2 -> ok (invoice 4411: total 1,250.00) 5. a key that is not enabled cannot unwrap data keys (the Deleting keys page: pending deletion cannot be used) a fresh process asks the key service to unwrap -> KmsError: DisabledException: the key is not enabled the running app that cached the plaintext data key -> ok (payroll) generate a NEW data key under the disabled key -> KmsError: DisabledException: the key is not enabled after re-enabling the key -> ok (payroll)

What the run shows. (1) 4,096 bytes go through directly and 4,097 do not. (2) 50 MB is encrypted with 16 bytes of overhead (the GCM tag), a 12-byte nonce and a 72-byte wrapped key in this stand-in's format; the timing is one run on this machine and says nothing about KMS, whose calls cross a network. (3) The encryption context is bound to the data: the wrong tenant, or no context, fails; one flipped bit fails; swapping in another object's wrapped key fails. (4) Rotation (as documented) adds key material and keeps the old, so old data still decrypts; "Key rotation has no effect on the data that the KMS key protects. It does not rotate the data keys ... or re-encrypt any data". Re-encrypting just the wrapped key (the ReEncrypt idea) moves an object to the current key version while the bulk ciphertext stays byte-for-byte the same (same hash). And if old key material were destroyed, anything still wrapped under it would be lost. (5) A key that is not enabled cannot unwrap or generate data keys, yet an application that already holds a plaintext data key in memory keeps working until it restarts. That matches the documentation's warning that scheduling deletion "might not immediately affect data keys encrypted by the KMS key", and it is why a key change can look harmless for hours and then break a restart.

The same pattern in a real library

The AWS Encryption SDK implements envelope encryption for you. It can use a KMS key, or a raw AES key you hold, and I could only use the second kind (no AWS account). So this run shows what the SDK does, not how a KMS keyring behaves. Run here (level 1): aws-encryption-sdk 4.0.7 with a raw AES-256 key.

py -X utf8 esdk_demo.py
aws-encryption-sdk 4.0.7 plaintext 29,000 bytes -> message 29,674 bytes (+674) message format version: SerializationVersion.V2 | algorithm: AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384 encrypted data keys in the header: 1 | commitment policy: REQUIRE_ENCRYPT_REQUIRE_DECRYPT encryption context in the header: {'app': 'invoices', 'tenant': 't-17', 'aws-crypto-public-key': 'A/wfJVxV2dqNxwFGgdEd...'} the bytes b'invoices' appear in the ciphertext message: True the bytes b't-17' appear in the ciphertext message: True the bytes b'1,250.00' appear in the ciphertext message: False decrypting with the right key -> ok (29000) with the wrong key (key-B only) -> DecryptKeyError: Unable to decrypt any data key the decrypt result also returns the context: True after flipping one bit near the end of the message -> InvalidSignature: after flipping one bit in the header -> InvalidTag: requiring the context on decrypt (the application's job: the SDK returns it, you must check it) expect tenant t-17 -> ok (context matches) expect tenant t-99 -> ValueError: context mismatch: {'tenant': 't-99'} two master keys: either one decrypts (a message can be readable by two keys, e.g. two Regions) encrypted data keys: 2 decrypt with key-A only -> ok (shared secret) decrypt with key-B only -> ok (shared secret)

Three findings. The encryption context is not secret: the words invoices and t-17 appear in clear text in the encrypted message (the KMS pages say the same: "Do not include confidential or sensitive information in this field"). The data itself does not appear. The SDK returns the context on decrypt, but you must check it: the "expect tenant t-99" line fails only because my code compared it. And damage anywhere fails: a flipped bit in the header or near the end of the message both fail (the library signs the message with its default algorithm suite; the algorithm name is in the output). A message can carry two encrypted data keys, so either of two keys (for example in two Regions) can decrypt it.

Key Policies, IAM Policies and Grants

KMS has an access model that surprises people who know IAM, and AWS Intermediate/Advanced 2 prepared you for it: a KMS key policy is a resource policy that must name its principals. The documentation:

  • "Key policies are the primary way to control access to KMS keys. Every KMS key must have exactly one key policy."
  • "No AWS principal, including the account root user or key creator, has any permissions to a KMS key unless they are explicitly allowed, and never denied, in a key policy, IAM policy, or grant."
  • "Unless the key policy explicitly allows it, you cannot use IAM policies to allow access to a KMS key. ... (You can use an IAM policy to deny a permission to a KMS key without permission from a key policy.)"
  • "Unlike IAM policies, which are global, key policies are Regional."

The default key policy for a key created through the API is one statement. The principal is the account root, and that does not give the root user the key: "When the principal in a key policy statement is the account principal, the policy statement doesn't give any IAM principal permission to use the KMS key. Instead, it allows the account to use IAM policies to delegate the permissions". Delete it and "IAM policies that allow access to the key are ineffective, although IAM policies that deny access to the key are still effective" (and you risk an unmanageable key).

{ "Version": "2012-10-17", "Statement": [ { "Sid": "Enable IAM User Permissions", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:root" }, "Action": "kms:*", "Resource": "*" } ] }

The console adds more: a key administrators statement (manage the key, not use it), a key users statement (use it for cryptographic operations), and one that lets users create grants for AWS services, only when kms:GrantIsForAWSResource is true:

{ "Version": "2012-10-17", "Statement": [ { "Sid": "Enable IAM User Permissions", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:root" }, "Action": "kms:*", "Resource": "*" }, { "Sid": "Allow access for Key Administrators", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:role/KeyAdmin" }, "Action": [ "kms:Create*", "kms:Describe*", "kms:Enable*", "kms:List*", "kms:Put*", "kms:Update*", "kms:Revoke*", "kms:Disable*", "kms:Get*", "kms:Delete*", "kms:TagResource", "kms:UntagResource", "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion", "kms:RotateKeyOnDemand" ], "Resource": "*" }, { "Sid": "Allow use of the key", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:role/KeyUser" }, "Action": [ "kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:DescribeKey" ], "Resource": "*" }, { "Sid": "Allow attachment of persistent resources", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:role/KeyUser" }, "Action": [ "kms:CreateGrant", "kms:ListGrants", "kms:RevokeGrant" ], "Resource": "*", "Condition": { "Bool": { "kms:GrantIsForAWSResource": true } } } ] }

A warning on the page that is easy to miss: "Because key administrators have permission to change the key policy and create grants, they can give themselves and others AWS KMS permissions not specified in this policy." Separating administrators from users stops accidents, not a determined administrator.

Grants

A grant is a second, temporary route to a key. It gives a grantee (an IAM identity, or an AWS service principal) specific operations on exactly one key; it can allow but never deny; it needs no change to the key policy or any IAM policy; and "an authorized principal can delete the grant (retire or revoke it)", which removes everything it allowed. AWS services use them: they create a grant on your behalf, use it, and retire it. Details from the page: the KMS API is eventually consistent, so a new grant may not work at once ("typically less than a few seconds ... in some cases it can take several minutes"); a grant token lets you use it immediately; and there is a limit of 50,000 grants per key. Treat kms:CreateGrant like kms:PutKeyPolicy: "Permission to create grants has security implications".

The rules as tests

I ran these rules through the AWS Intermediate/Advanced 2 evaluator, using its "this resource policy must explicitly allow the principal" mode (explicit_resource_allow), which was built for exactly this case, and added grants and key state around it. As before, each expected answer comes from a sentence on the pages and was written first. (Level 1: a model of the documented rules, not AWS.)

py -X utf8 kms_policy_model.py
case (source: AWS KMS developer guide page named in the script) docs model result default key policy (root statement) + IAM policy allows kms:Decrypt for the role -> Decrypt Allow Allow match default key policy, role has NO IAM policy -> Decrypt (the root statement only DELEGATES) Deny Deny match key policy names only one user (no root statement); IAM policy allows the role -> Decrypt Deny Deny match key policy names the role directly for Decrypt; role has no IAM policy -> Decrypt Allow Allow match key policy names the role for Decrypt only -> Encrypt (not listed) Deny Deny match IAM Deny, and the key policy has no statement for the role at all -> Decrypt (a Deny needs no key policy permission) Deny Deny match key policy has an explicit Deny for the role even though IAM allows -> Decrypt Deny Deny match administrator role: kms:Decrypt (the admins statement has no cryptographic actions) Deny Deny match administrator role: kms:PutKeyPolicy Allow Allow match administrator role: kms:CreateGrant (kms:Create* includes it) Allow Allow match administrator role: kms:ScheduleKeyDeletion Allow Allow match key user role: kms:Decrypt Allow Allow match key user role: kms:GenerateDataKeyWithoutPlaintext (GenerateDataKey* wildcard) Allow Allow match key user role: kms:PutKeyPolicy Deny Deny match key user role: kms:ScheduleKeyDeletion Deny Deny match the warning: an administrator creates a grant for themselves, THEN calls kms:Decrypt Allow Allow match key policy names account B's root AND B's role has an IAM policy for the key ARN -> Decrypt Allow Allow match key policy names account B's root, but B's role has no IAM policy -> Decrypt Deny Deny match B's role has an IAM policy, but the key policy does not name account B -> Decrypt Deny Deny match a grant for the role, and NO key policy statement or IAM policy for it -> Decrypt Allow Allow match same grant -> Encrypt (not among the grant's operations) Deny Deny match the grant, but an IAM Deny on kms:Decrypt -> Decrypt (a grant cannot override a Deny) Deny Deny match the grant is retired (removed from the list) -> Decrypt Deny Deny match key is pending deletion; policies and grants all allow -> Decrypt Deny Deny match key is pending deletion -> kms:CancelKeyDeletion by the administrator (management, not cryptographic) Allow Allow match a grant with a constraint -> the model refuses (not modelled) (err) (err) match key user calls kms:CreateGrant directly (kms:GrantIsForAWSResource is absent) Deny Deny match key user creates the grant through an AWS service (kms:GrantIsForAWSResource = true) Allow Allow match identity policy requires context app=invoices; request carries app=invoices -> Decrypt Allow Allow match same policy; request carries app=payroll -> Decrypt Deny Deny match same policy; request carries no encryption context at all -> Decrypt (the key is missing, so the condition is false) Deny Deny match 31 cases, mismatches: 0

All 31 match; the control with one flipped expectation reports 31 cases, mismatches: 1. Read it as a checklist. The root statement delegates (no IAM policy, no access). A key policy that names only one user makes everyone else's IAM allows useless. An administrator cannot decrypt, but can put a key policy, create a grant and schedule deletion, and the "warning" row shows the two-step path from administrator to user. A cross-account request needs both the key policy naming the other account and an IAM policy in that account. A grant works with no policy statement but cannot beat a Deny. A key pending deletion refuses cryptographic operations whatever allows them. Grants with constraints are refused by the model (not implemented).

The cross-account pair, as policy files. In the key's account, the key policy names the other account; in the other account, a role needs its own policy naming the key ARN:

{ "Version": "2012-10-17", "Statement": [ { "Sid": "Enable IAM User Permissions", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:root" }, "Action": "kms:*", "Resource": "*" }, { "Sid": "Allow account B to use the key", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::444455556666:root" }, "Action": [ "kms:Decrypt", "kms:DescribeKey" ], "Resource": "*" } ] }
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "kms:Decrypt", "kms:DescribeKey" ], "Resource": "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" } ] }

One more control you can put in an identity policy: bind use of a key to an encryption context with the condition key form kms:EncryptionContext:<key> (the last three rows of the table above: the matching context is allowed, a different one is denied, and a request with no context at all is denied because a missing key makes the condition false):

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "kms:Decrypt", "kms:GenerateDataKey" ], "Resource": "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", "Condition": { "StringEquals": { "kms:EncryptionContext:app": "invoices" } } } ] }

Rotation and Deletion of Keys

Rotation (the Rotate keys page):

  • Automatic rotation is optional for customer managed keys, on symmetric encryption keys whose key material AWS KMS generated. When you enable it, "by default ... AWS KMS generates new cryptographic material for the KMS key every year"; the default rotation period is 365 days, and you can set a custom period. On-demand rotation rotates immediately, even with automatic rotation off.
  • Asymmetric keys, HMAC keys and keys in custom key stores cannot be rotated automatically or on demand; you create a new key and switch your applications or alias to it (manual rotation).
  • Rotation changes only the current key material. "AWS KMS retains all key material for a KMS key with AWS_KMS origin, even if key rotation is disabled", the key ID and ARN do not change, and decryption "automatically chooses the correct version". It does not re-encrypt data or rotate data keys, and "will not mitigate the effect of a compromised data key".
  • It is recorded: a RotateKey event in CloudTrail and a KMS CMK Rotation event in EventBridge. Cost: see above ($1 per month extra for the first two rotations).

The page is candid about why: KMS keys are mostly wrapping keys that are used rarely, so "they ... are almost never reused enough to risk key exhaustion", and you rotate mainly "due to business or contract rules or government regulations". The envelope-encryption run above is the reason it is cheap: rotation never touches the bulk data.

Deletion (the Deleting keys page) is the one operation you cannot undo:

  • "After a KMS key is deleted, you can no longer decrypt the data that was encrypted under that KMS key, which means that data becomes unrecoverable." For a symmetric key this holds even if you have the same key material, because "metadata unique to each KMS key is cryptographically bound to each symmetric ciphertext".
  • You schedule deletion with a waiting period of 7 to 30 days (default 30); the real wait "might be up to 24 hours longer". During it the key is Pending deletion: it cannot be used for cryptographic operations and is not rotated, and you can cancel. The page suggests disabling the key if you are not sure, and a CloudWatch alarm that warns if anything tries to use a key during the waiting period.
  • You can delete only customer managed keys. Closing the AWS account makes keys inaccessible and stops the billing.

Secrets Manager and Parameter Store

The Secrets Manager introduction contains a useful decision table of its own: "AWS credentials: we recommend IAM. Encryption keys: we recommend KMS. SSH keys: we recommend Amazon EC2 Instance Connect. Private keys and certificates: we recommend AWS Certificate Manager." What is left for Secrets Manager is "database credentials, application credentials, OAuth tokens, API keys, and other secrets", and its pitch is rotation: "you replace hard-coded credentials with a runtime call to the Secrets Manager service". Systems Manager Parameter Store holds configuration and small secrets more cheaply. The facts, from the documentation:

Secrets ManagerParameter Store, standardParameter Store, advanced
Maximum value size65,536 bytes4 KB8 KB
How many500,000 secrets per Region10,000 per account and Region100,000 per account and Region
Cost (as read 2026-10-12)$0.40 per secret per month and $0.05 per 10,000 API calls; the page also says a free plan is "available for 6 months after account creation". Encrypting with the free AWS managed key aws/secretsmanager costs nothing extra; your own KMS key is charged at KMS rates. No charge for secrets marked for deletion"No additional charge""Charges apply" (the price was not read)
RotationBuilt in: managed rotation, or a Lambda function you run (charged at Lambda rates)None built inNone built in
VersionsUp to 100 versions, staging labels (20 labels across all versions)(Not read)
Policies and sharingResource-based policy up to 20,480 characters; replication to other Regions is an API operationNo parameter policies; no sharing across accountsParameter policies; sharing across accounts
Changing tierStandard can be upgraded; advanced cannot be downgraded, because it would truncate the value to 4 KB, drop the policies, and "advanced and standard parameters use a different form of encryption"

Run here (level 1): will real key material fit? I generated RSA, EC and certificate PEM text and measured it. The costs below are arithmetic on the prices above (not a quote; no Region named; the free plan is not applied).

py -X utf8 secrets_compare.py
1. does it fit? (bytes of the PEM / JSON text) item bytes Parameter Store standard Parameter Store advanced Secrets Manager database credentials as JSON 113 fits fits fits EC P-256 private key (PEM) 241 fits fits fits RSA-2048 private key (PEM) 1704 fits fits fits RSA-4096 private key (PEM) 3272 fits fits fits RSA-2048 key + 3-certificate chain 4767 TOO BIG fits fits RSA-4096 key + 3-certificate chain 6335 TOO BIG fits fits 2. what reading a secret costs: arithmetic on the prices above (Secrets Manager only; Parameter Store standard has no additional charge) each of 200 container starts a day reads every secret once 150,000 calls storage $ 10.00 + calls $ 0.75 = $ 10.75 per month each request reads every secret: 10 requests/s 648,000,000 calls storage $ 10.00 + calls $ 3240.00 = $ 3250.00 per month the same 10 requests/s, with a 5-minute cache in each of 20 instances 4,320,000 calls storage $ 10.00 + calls $ 21.60 = $ 31.60 per month (25 secrets; the free-trial rules and Regional price differences were not stated on the page and are not applied)

The fit table is a measurement, and it contains a trap: a 4,096-bit RSA key fits Parameter Store standard on its own (3,272 bytes), but the same key with a three-certificate chain does not (6,335 bytes). The cost table is the lesson about where you call: reading 25 secrets on every request at 10 requests per second is about $3,250 a month in API calls, while a five-minute cache in 20 instances is about $32. Cache secrets in memory and re-read on a schedule or on authentication failure. (How rotation interacts with a cache is the next section.)

The rotation protocol

A rotation Lambda function does four jobs, and Secrets Manager tracks them with staging labels: AWSCURRENT is the version applications read; AWSPENDING is the candidate; AWSPREVIOUS is the last good one. From the page:

  1. createSecret checks whether a version with this request token exists; if not it makes a new value and stores it with the label AWSPENDING. "Storing the new secret value in AWSPENDING helps ensure idempotency."
  2. setSecret changes the credential in the database or service to match AWSPENDING. The function is "a privileged deputy", so before it acts it must check that AWSCURRENT is valid, that both versions are for the same user, and that the target host is the same, otherwise someone who can edit a secret could point the function at another resource (the confused deputy problem from AWS Intermediate/Advanced 2).
  3. testSecret uses AWSPENDING against the database (the templates use read access).
  4. finishSecret moves AWSCURRENT to the new version; Secrets Manager adds AWSPREVIOUS to the old one. "Don't remove AWSPENDING before this point". If AWSPENDING is left on a different version than AWSCURRENT, "any later invocation of rotation assumes that a previous rotation request is still in progress and returns an error".

Other documented rules: only the rotation function can rotate the secret (it cannot call another Lambda function to do it); a provisioned concurrency below 10 "can cause throttling"; and be careful what you log, because logs go to CloudWatch. I turned these into a small model:

def createSecret(): if token not in sec.versions: # idempotent: reuse the pending version if this token already made one c = parse(sec.get()) sec.put(f"{c['user']}:{pysecrets.token_urlsafe(8)}:{c['host']}", token, ["AWSPENDING"]) def setSecret(): c, p = parse(sec.get()), parse(sec.versions[token]["value"]) if not db.connect(c["user"], c["password"], c["host"]): raise SmError("abandoned: the AWSCURRENT credential is not valid") if (c["user"], c["host"]) != (p["user"], p["host"]): raise SmError(f"abandoned: AWSCURRENT and AWSPENDING are for different resources ({c['user']}@{c['host']} vs {p['user']}@{p['host']})") db.password = p["password"] def testSecret(): p = parse(sec.versions[token]["value"]) if not db.connect(p["user"], p["password"], p["host"]): raise SmError("the AWSPENDING credential does not work") def finishSecret(): sec.move_current(token) for name, fn in (("createSecret", createSecret), ("setSecret", setSecret), ("testSecret", testSecret), ("finishSecret", finishSecret)):

Run here (level 1): a model of the documented protocol, not AWS. "My reading" marks two things the page does not state outright: that a retry with the same token resumes, and that a put which cannot make room under the 100-version quota fails.

py -X utf8 rotation_model.py
1. a normal rotation, step by step (the model prints the state after each step) labels: v1=AWSCURRENT app reading AWSCURRENT can connect: True (start) labels: v1=AWSCURRENT, v2=AWSPENDING app reading AWSCURRENT can connect: True (after createSecret) labels: v1=AWSCURRENT, v2=AWSPENDING app reading AWSCURRENT can connect: False (after setSecret) labels: v1=AWSCURRENT, v2=AWSPENDING app reading AWSCURRENT can connect: False (after testSecret) labels: v2=AWSCURRENT/AWSPENDING, v1=AWSPREVIOUS app reading AWSCURRENT can connect: True (after finishSecret) 2. failure inside setSecret, then a retry with the same token rotate(token v2), failing in setSecret -> SmError: simulated failure in setSecret labels: v1=AWSCURRENT, v2=AWSPENDING app reading AWSCURRENT can connect: True (application unaffected) retry rotate(token v2) -> ok ['createSecret', 'setSecret', 'testSecret', 'finishSecret'] the retry reused the pending value: True labels: v2=AWSCURRENT/AWSPENDING, v1=AWSPREVIOUS app reading AWSCURRENT can connect: True (after retry) 3. a rotation left in progress blocks a rotation with a different token first rotation fails in testSecret -> SmError: simulated failure in testSecret a new rotation with token v3 -> SmError: a previous rotation is still in progress (AWSPENDING is on another version) the same token v2 again -> SmError: abandoned: the AWSCURRENT credential is not valid 4. the confused-deputy checks in setSecret an attacker edits the AWSPENDING version to user 'admin' on the same host -> SmError: abandoned: AWSCURRENT and AWSPENDING are for different resources (app@db1 vs admin@db1) database password unchanged: True an attacker points AWSPENDING at another host -> SmError: abandoned: AWSCURRENT and AWSPENDING are for different resources (app@db1 vs app@db-other) the AWSCURRENT credential no longer works -> SmError: abandoned: the AWSCURRENT credential is not valid 5. quotas (value size, and the 100-version limit with the 24-hour rule) put a value of 65,536 bytes -> ok put a value of 65,537 bytes -> SmError: secret value is 65537 bytes; the limit is 65536 an update every 5 min: failed at update 101 (after 8.3 h): LimitExceeded: 100 versions and none can be removed (unlabel an update every 10 min: failed at update 101 (after 16.7 h): LimitExceeded: 100 versions and none can be removed (unlabel an update every 15 min for 3 days: 286 updates, 100 versions kept, never hit the limit

Four things to take from it. First, between setSecret and finishSecret the database already has the new password while AWSCURRENT still holds the old one, so an application that reads AWSCURRENT in that window cannot connect. That window is a consequence of the four steps as written (derived, not a quoted sentence); the documentation lists a second rotation strategy ("alternating users") and a managed rotation, which I did not read, so I do not describe how they avoid it. Second, a failure inside setSecret leaves the application untouched and a retry with the same token reuses the pending value. Third, a failure after the database change (the testSecret case) leaves AWSCURRENT wrong, and then even a retry is abandoned by the "is AWSCURRENT valid" check: a real operational hazard that the model makes visible, and why your application should retry with a fresh read. Fourth, the quota line: updating a secret more often than the page recommends (once every 10 minutes at most) eventually runs out of versions, because unlabelled versions younger than 24 hours are never removed; at one update every five minutes the model fails after 8.3 hours.

Certificates: ACM

AWS Certificate Manager "handles the complexity of creating, storing, and renewing public and private SSL/TLS X.509 certificates and keys". Facts from its overview page (level 3, not run): certificates can be issued by ACM or imported; wildcard certificates are supported; there is "no additional charge for SSL/TLS certificates that you manage with AWS Certificate Manager" (you pay for the resources that use them); certificates are regional and "you cannot copy a certificate between regions"; and to use one with CloudFront "you must request or import the certificate in the US East (N. Virginia) region". ACM certificates signed by AWS Private CA can be exported; for publicly trusted certificates on your own servers (EC2, say) the page points to the ACME protocol. I did not read the validity period or renewal rules, so I do not state them. HTTPS/TLS Fundamentals covers what the certificate itself is.

Secrets in Environment Variables

"Put it in an environment variable" is the common first step away from hard-coding. It is better than source code and worse than most people think. These are real experiments with child processes on this Windows machine (the password is made up):

py -X utf8 secret_leaks.py
1. an environment variable is inherited by every child process, and by their children child sees: hunter2 grandchild sees: hunter2 child started with the variable removed: None 2. a 'debug dump' of the environment leaks it; redacting by key name misses a password inside a URL raw dump contains the secret: True after redacting variable NAMES that look sensitive: {"DATABASE_URL": "postgres://app:hunter2@db1.example.internal:5432/shop", "DB_PASSWORD": "***", "LOG_LEVEL": "debug"} the secret still appears: True (inside DATABASE_URL: the name has no PASS/SECRET/TOKEN/KEY in it) 3. a command-line argument is readable by any other process of the same user, while the program runs another process reads its command line: <python.exe> -c "import time; time.sleep(25)" --password=hunter2 4. an alternative: pass a FILE PATH (not the secret), or send the secret over a pipe at start-up another process reads its command line: <python.exe> -c "import sys,time; print('read from file:', open(sys.argv[1]).read()); time.sleep(25)" <temp>/demo_secret.txt the secret appears in that command line: False child got the secret over a pipe, length 7 | environment copy in the child: None (a file or a pipe still has to be protected: the file needs permissions, and anything the process prints can still leak it)

What they show. Inheritance: every child and grandchild sees the variable unless you remove it deliberately. Dumps: a diagnostic that prints the environment leaks it, and filtering by variable name fails when the secret hides inside DATABASE_URL. Command lines: an argument is readable by another process of the same user while it runs (here through Get-CimInstance Win32_Process), so never pass a secret as an argument. Alternatives: passing a file path, or writing the secret to the child's standard input at start-up, keeps it out of the command line and the environment, but the file needs tight permissions and anything the program prints can still leak it. I did not run docker inspect (the engine was not running); I am not claiming what it shows. The documented pattern is to fetch the secret at run time, cache it in memory, and refresh it (see the cost table above).

Evidence: CloudTrail and KMS

CloudTrail "records all calls to AWS KMS by users, roles, and other AWS services", including read-only calls, key management and cryptographic operations such as GenerateDataKey and Decrypt, and internal operations such as RotateKey and DeleteKey. Details: failed calls are logged "in some scenarios"; cross-account operations are logged in both accounts, but cross-account requests rejected for access are logged "only in the caller's account"; some fields are omitted ("the Plaintext parameter of an Encrypt request, and the response to ... any cryptographic operation"); and the encryption context may appear in plain text. One warning to remember: a trail can exclude all KMS events (ExcludeManagementEventSources set to kms.amazonaws.com), and "excluding AWS KMS events from a CloudTrail Log can obscure actions that use your KMS keys", so guard cloudtrail:PutEventSelectors. Look for a PutEventSelectors event with that setting if KMS events seem to be missing.

Run here (level 1): I took the three example log entries the KMS documentation prints for Decrypt and GenerateDataKey, and added 18 invented events in the same layout (marked synthetic: I had no real CloudTrail data) so there is something to find. Note the first result: the documentation's own examples are not all valid JSON.

py -X utf8 ct_analyze.py
1. the documentation's own example events: do they parse? decrypt_ok.json valid JSON as printed decrypt_fail_as_printed.json NOT valid JSON as printed: Expecting ',' delimiter (line 18 column 5); the line before is '"errorCode": "IncorrectKeyException"' after inserting one comma: valid, 18 top-level fields generatedatakey_as_printed.json NOT valid JSON as printed: Expecting ',' delimiter (line 39 column 5); the line before is '"recipientAccountId": "111122223333"' after inserting one comma: valid, 20 top-level fields 21 events in the analysis: 3 from the documentation, 18 synthetic 2. data-quality check on the documentation's events: does the key ARN's Region match the event's awsRegion? Decrypt awsRegion us-west-2 key us-west-2 Decrypt awsRegion us-west-2 key us-west-2 GenerateDataKey awsRegion us-east-1 key us-west-2 <-- differs 3. who used which key (events with a principal and a key; counts per principal) role App/i-0abc key ...1234567890ab Decrypt 6 role App/i-0abc key ...1234567890ab GenerateDataKey 6 role Batch/job-7 key ...1234567890ab Decrypt 1 role Batch/job-7 key ...999999999999 Decrypt 3 role KeyAdmin/carol key ...999999999999 CancelKeyDeletion 1 role KeyAdmin/carol key ...999999999999 ScheduleKeyDeletion 1 user Alice key ...1234567890ab Decrypt 2 user Alice key ...1234567890ab GenerateDataKey 1 4. failures (errorCode) and what the request said 2022-11-24T18:57:43Z Decrypt Alice IncorrectKeyException context {'Department': 'Engineering', 'Project': 'Alpha'} 2026-10-11T14:00:00Z Decrypt Batch InvalidCiphertextException context {'app': 'invoices', 'tenant': 't-99'} 2026-10-11T17:10:00Z Decrypt Batch KMSInvalidStateException context {'app': 'payroll'} 2026-10-11T17:40:00Z Decrypt Batch KMSInvalidStateException context {'app': 'payroll'} 5. the encryption context is plain text in the log: values that appear Department: ['Engineering'] Project: ['Alpha'] app: ['invoices', 'payroll'] tenant: ['t-17', 't-99'] 6. the documented alarm idea: any attempt to use a key that is pending deletion key ...999999999999 scheduled for deletion 2026-10-11T15:00:00Z, cancelled 2026-10-11T18:00:00Z: 2 attempted uses in between 2026-10-11T17:10:00Z Decrypt by Batch -> KMSInvalidStateException 2026-10-11T17:40:00Z Decrypt by Batch -> KMSInvalidStateException

Two of the three printed examples are missing a comma, so any tool that loads them as given fails (see Working with JSON 1; I inserted one comma in each to analyse them). The check that compared the event's Region with the key ARN's Region flags the GenerateDataKey example as inconsistent (us-east-1 against a us-west-2 key): it is an example, but it shows the sort of mismatch worth alerting on in real data. The questions that matter are answerable from the fields: who used which key (userIdentity and resources), which calls failed and why (errorCode), what context they used, and, from the last section, whether anything touched a key that was pending deletion.

Offline Checks: the SDK and parliament

Two more things you can check before any AWS call. The first is how much of the KMS request limits the SDK enforces; the second is what a policy linter says about the files in this chapter. Run here (level 1):

py -X utf8 kms_botocore_lint.py
botocore 1.43.112; every call goes to Stubber, nothing is sent case what the SDK did expectation from the docs Encrypt: 1 byte of plaintext accepted ok Encrypt: 0 bytes (documented minimum is 1) accepted reject Encrypt: 4,096 bytes (documented maximum) accepted ok Encrypt: 4,097 bytes (over the documented maximum) accepted reject (documented) Encrypt: encryption context with a non-string value REJECTED: Invalid type for parameter EncryptionContext reject Encrypt: 11 grant tokens (documented maximum 10) accepted reject (documented) Encrypt: no KeyId REJECTED: Missing required parameter in input: "KeyId" reject parliament on the policy files in pol/ (it reads each as an IDENTITY policy) iam_policy_account_b_role.json ['(no findings)'] iam_policy_context_bound.json ['(no findings)'] key_policy_allow_other_account.json ['INVALID_SID', 'INVALID_SID'] key_policy_console_style.json ['INVALID_SID', 'INVALID_SID', 'INVALID_SID', 'INVALID_SID'] key_policy_default.json ['INVALID_SID']

botocore accepted a 0-byte and a 4,097-byte Plaintext and 11 grant tokens: here it checked types and required fields only, not the lengths the documentation states (AWS would reject those, according to the pages; I could not send a request to see). Consistent with AWS Intermediate/Advanced 2: do not treat an SDK's silence as validation. And parliament, which reads everything as an identity policy, flagged the Sid of the documented default key policy ("Enable IAM User Permissions" contains spaces) as INVALID_SID. That is its identity-policy rule applied to a key policy: key policies are a different policy type, and the linter does not know. The two identity policies (the cross-account role policy and the context-bound policy) had no findings.

Cost and Clean-Up

Nothing in this chapter creates anything in AWS, so there is nothing to delete and nothing to pay. When you do build it for real, the recurring costs to know are: $1 per month per customer managed key (plus $1 for each of the first two rotations), a per-request price, and for Secrets Manager $0.40 per secret per month plus calls (prices as read 2026-10-12, no Region stated, free plans not applied). A key you no longer need costs money until it is deleted, and deletion takes 7 to 30 days and cannot be undone; disable first. Put a budget alert in place before you start (AWS Fundamentals 9), and use the sandbox account from AWS Intermediate/Advanced 1.

What was and was not checked
Run here: AES-GCM envelope encryption with a stand-in key service (sizes, failures, rotation and disabled-key behaviour); the AWS Encryption SDK with a raw key; 31 documented key-policy, IAM and grant outcomes through the evaluator model; the rotation protocol model; key and certificate sizes against the documented limits; the environment-variable and command-line experiments; CloudTrail event analysis on three documented examples plus 18 invented events; botocore offline parameter checks; parliament on five policy files. From the documentation, not run: every statement about KMS, S3, EBS, RDS, Secrets Manager, Parameter Store, ACM and CloudTrail behaviour, and every price. Not read: Parameter Store's SecureString details, Secrets Manager's alternating-users and managed rotation, ACM validity and renewal, advanced Parameter Store prices. Not run at all: any real KMS call, key, grant, rotation or secret; docker inspect (engine not running); Lambda or ECS environment settings; other SDKs or versions.

Practice

Exercise 1

Build envelope encryption by hand with AES-GCM and a stand-in key service that copies the documented behaviours (4,096-byte direct limit, key version stored in the blob, encryption context as authenticated data, rotation that keeps old material, a disabled key). Show the context, tamper and swapped-key failures, a rotation that leaves the bulk ciphertext unchanged, and a running process that keeps working on a cached data key. Then repeat the pattern with the AWS Encryption SDK and a raw key, and show what is and is not visible in the encrypted message.

๐Ÿ“„ View solution
Exercise 2

Write the KMS key policies from the documentation (default, console-style, cross-account) and run the documented access rules as tests through an evaluator: delegation by the root statement, a key policy that names only one user, administrators versus users, the administrator-grants-themselves path, cross-account use needing both sides, grants that allow but never deny, a key pending deletion, and an encryption-context condition. Add the offline SDK and linter checks and report what each does and does not catch.

๐Ÿ“„ View solution
Exercise 3

Model the Secrets Manager rotation protocol (labels, four steps, the confused-deputy checks, the in-progress rule, the version quota), measure real key and certificate sizes against the Parameter Store and Secrets Manager limits and compute reading costs, run the environment-variable and command-line leak experiments, and analyse KMS CloudTrail events, including the documentation's own examples that fail to parse.

๐Ÿ“„ View solution

Chapter 3 Quick Reference

  • Defaults: S3 encrypts all new objects (SSE-S3) since 2023-01-05 and cannot be turned off; EBS needs the per-Region "encryption by default" setting and does not change existing volumes; RDS encrypts only at creation and the key cannot be changed afterwards
  • KMS keys: customer managed ($1/month, full control, optional rotation), AWS managed (free to exist, yearly rotation, not shareable), AWS owned (invisible, free). Direct Encrypt is limited to 4,096 bytes: use envelope encryption (GenerateDataKey)
  • The encryption context is authenticated but not secret: it must match exactly on decrypt, and it appears in clear text in messages and CloudTrail. Never put secrets in it
  • A key policy must name its principals. The root statement only delegates to IAM policies; without it, IAM allows do nothing (IAM denies still work). Key policies are Regional
  • Key administrators manage but cannot use a key, yet can edit the key policy and create grants, so they can give themselves use. Cross-account needs the key policy naming the other account and an IAM policy in that account
  • Grants allow a grantee specific operations on one key, never deny, and vanish when retired or revoked; they are eventually consistent (use a grant token to act at once)
  • Rotation changes key material only: old material is kept, data and data keys are not re-encrypted. Deletion is irreversible, with a 7 to 30 day waiting period (default 30): disable first and alarm on use during the wait
  • Secrets Manager: rotation built in, 65,536-byte values, $0.40 per secret per month plus calls. Parameter Store: standard 4 KB and free, advanced 8 KB with policies and sharing; no built-in rotation. Certificates belong in ACM, encryption keys in KMS, AWS credentials in IAM
  • Rotation = createSecret, setSecret, testSecret, finishSecret with labels AWSPENDING, AWSCURRENT, AWSPREVIOUS; the function must check the same user and host before acting; a stale AWSPENDING blocks the next rotation. Cache secrets: reading on every request is the expensive mistake
  • Environment variables are inherited by every child, leak through diagnostic dumps (even when you redact by name), and command-line arguments are readable by other processes: fetch at run time, keep in memory
  • CloudTrail logs KMS calls (both accounts for cross-account, denied cross-account only for the caller); do not exclude KMS events from a trail without a reason, and do not trust an SDK's silence as validation
Coming next
Chapter 4 is infrastructure as code with CloudFormation: templates, parameters, conditions, change sets, rollback and drift, with every template linted for real. Everything in this chapter (keys, key policies, secrets, rotation) is something you will want to define in a template rather than click. Start with AWS Intermediate/Advanced 4.