Mapping CUI Data Flows in AWS Multi-VPC Environments
vpc-public → vpc-app → vpc-cui, no other route exists
vpc-public
ALB, WAF, internet-facing
vpc-app
EC2 / ECS compute
vpc-cui
No IGW route, ever
Controlled Unclassified Information (CUI) doesn't get a special AWS service. There's no "CUI mode" you flip on. What you get is a set of controls in NIST SP 800-171, and the job is translating those controls into an account structure, a network topology, and a key management setup that an auditor can actually walk through. This is that translation, done once, so you're not re-deriving it per engagement.
The starting mistake: one VPC, tagged resources
The most common failure mode isn't a missing control: it's architecture that makes the controls unverifiable. A single VPC with CUI-handling instances distinguished only by resource tags technically satisfies "we know which resources touch CUI," but it fails the boundary-protection requirement (3.1.3) the moment someone asks "show me the network path that proves this instance can't reach the public internet." If the answer requires reading fifteen security group rules and hoping none of them conflict, that's not a boundary. That's a policy document describing a boundary that doesn't structurally exist.
The fix is a dedicated VPC for anything that touches CUI, full stop. Not a subnet. Not a security group. A VPC, so the boundary shows up in the account's actual network topology, not just in your documentation.
A minimal multi-VPC layout
Three VPCs, each with one job:
- `vpc-public`: internet-facing services (ALB, API Gateway, WAF). No CUI ever lands here.
- `vpc-app`: application compute (EC2/ECS/EBS). Talks to
vpc-publicinbound andvpc-cuioutbound, nothing else. - `vpc-cui`: CUI-adjacent storage: RDS, KMS-encrypted EBS, any service that reads or writes controlled data. No route to the internet gateway, no route to
vpc-public, ever.
Connectivity between them goes through a Transit Gateway with explicit route tables per attachment, not a full mesh of VPC peering, which tends to accumulate routes nobody remembers approving. Each attachment gets its own route table, and vpc-cui's route table has exactly one route beyond local: the /32 or /24 that covers the specific app-tier subnets allowed to reach it.
resource "aws_ec2_transit_gateway_route_table" "cui" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
tags = { Name = "tgw-rt-cui-inbound-only" }
}
resource "aws_ec2_transit_gateway_route" "app_to_cui" {
destination_cidr_block = "10.20.0.0/24" # vpc-cui CIDR
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.app.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.cui.id
}
# No route to vpc-public. No route to the internet. If it isn't
# listed here, vpc-cui cannot reach it, and that's enforced by
# the absence of a route, not by a security group someone could edit.Security groups are the second layer, not the first. vpc-cui's RDS security group allows inbound 5432 from exactly the app tier's security group ID, never a CIDR range, never 0.0.0.0/0 even scoped to VPC. Security-group-to-security-group references mean the rule stays correct even when instances get replaced.
Encryption: envelope encryption, not "encryption enabled"
"Encrypted at rest" is a checkbox in the RDS console, but the control you actually need to demonstrate (3.13.11) is that you control the key lifecycle. That's what a customer-managed KMS key gets you over the AWS-managed default: you can rotate it, restrict who can use it via key policy, and revoke access without deleting the underlying data.
Envelope encryption is the pattern under the hood, and it's worth understanding directly rather than trusting the RDS abstraction, because you'll need to implement it yourself for anything outside a managed service (application-level encryption of a specific field, for instance).
import boto3
kms = boto3.client("kms", region_name="us-gov-west-1")
# 1. Ask KMS for a data key. It returns both the plaintext key
# (used once, in memory, then discarded) and the same key
# encrypted under your CMK (stored alongside the ciphertext).
response = kms.generate_data_key(
KeyId="arn:aws:kms:us-gov-west-1:111122223333:key/cui-cmk",
KeySpec="AES_256",
)
plaintext_key = response["Plaintext"]
encrypted_key = response["CiphertextBlob"]
# 2. Encrypt the actual payload locally with the plaintext key
# (AES-GCM, application-level, not shown here) and store
# ciphertext + encrypted_key together. Discard plaintext_key.
# 3. To decrypt later: ask KMS to unwrap encrypted_key, which
# requires kms:Decrypt permission on the CMK: this is your
# real access-control point, not the S3/RDS ACL.
decrypted = kms.decrypt(CiphertextBlob=encrypted_key)["Plaintext"]The reason this matters for CUI specifically: your key policy is a control you can point an auditor at directly. "Only the cui-app-role and cui-backup-role IAM roles can call kms:Decrypt on this key" is a falsifiable, auditable statement. "The database has encryption enabled" is not. It doesn't say who can read the plaintext.
GuardDuty as the boundary's alarm system
A boundary that isn't monitored is a boundary you're trusting rather than verifying. GuardDuty in vpc-cui's account, with VPC Flow Logs and DNS logs as data sources, catches the failure modes that matter here specifically:
aws guardduty create-detector \
--enable \
--finding-publishing-frequency FIFTEEN_MINUTES \
--data-sources '{"S3Logs":{"Enable":true},"Kubernetes":{"AuditLogs":{"Enable":false}}}'The findings worth routing to a pager rather than a weekly digest, for a CUI boundary specifically:
UnauthorizedAccess:EC2/TorIPCaller: something invpc-cuireached the internet at all, which given the route table above should be structurally impossible, so this finding means the route table changed underneath you.Recon:IAMUser/MaliciousIPCallerscoped to thecui-app-role: credential reuse from outside the expected CIDR.Trojan:EC2/DriveBySourceTraffic: outbound connection pattern from an instance that has no business making outbound connections.
Wire these through EventBridge to a Lambda that checks severity and pages only on High/Critical. GuardDuty's Low-severity findings are frequent enough that paging on all of them trains people to ignore the pager.
Mapping to NIST SP 800-171 / CMMC Level 2
This is the table an assessor actually wants, control ID to concrete implementation:
| Control | Requirement | Implementation |
|---|---|---|
| 3.1.3 | Control CUI flow, boundary enforcement | Dedicated vpc-cui, no IGW route, TGW route table allow-list |
| 3.1.13 | Cryptographic mechanisms for remote access | Session Manager instead of bastion + SSH; no inbound 22 anywhere |
| 3.13.8 | Cryptographic mechanisms during transmission | TLS 1.2+ enforced at ALB, VPC endpoints (not internet) for AWS API calls from vpc-cui |
| 3.13.11 | FIPS-validated cryptography | KMS with customer-managed keys, AWS's FIPS 140-2 validated HSM boundary |
| 3.14.6 | Monitor for unauthorized connections | GuardDuty + VPC Flow Logs, routed to a SIEM with 12-month retention |
| 3.3.1 | Audit log creation and retention | CloudTrail with log file validation, S3 Object Lock (see the CloudTrail/QLDB piece for the full pipeline) |
None of this is exotic. It's mechanical: the work is doing it before the architecture exists informally and has to be retrofitted, which is always more expensive and always leaves gaps the informal version never surfaces until an assessment does.
Building something like this?
Tell us what you're working on, we'll scope it together.
More from the blog
Writing Automated Scanners to Audit Network Topology Diagrams
Catching open ports, unencrypted transit routes, and single points of failure in exported topology diagrams before a single resource gets provisioned.
Immutable CloudTrail Auditing via AWS QLDB & S3 Object Lock
A tamper-evident logging pipeline for financial and enterprise compliance, and an honest look at when you actually need the extra layer.
