Secure a Fintech Data Stack on AWS

Lock down S3, CloudFront, WAF, and RDS for a fintech security stack.

Introduction

30 Second Summary

Every fintech company handles sensitive financial data that attackers constantly target. A single misconfigured storage bucket or unencrypted database connection can expose millions of customer records.

In this project, you will build a production-grade security stack on AWS for a fictional fintech company called "VaultPay." You will lock down customer data files, shield them with a web application firewall, and launch an encrypted database that refuses unencrypted connections.

What You'll Build

Imagine sending a SQL injection attack at your CloudFront URL and watching it get blocked with a 403 Forbidden response, while legitimate requests flow through to your encrypted, privately stored customer files.

By the end of this project, you'll have:

  • An S3 bucket encrypted with KMS and locked behind Block Public Access, accessible only through CloudFront with Origin Access Control.
  • An AWS WAF layer that inspects every request at the edge, blocking SQL injection and cross-site scripting payloads before they reach your data.
  • An encrypted RDS database in a private subnet that refuses unencrypted connections and only accepts traffic from your application's security group.
  • Secret Mission: Add a rate-based WAF rule to throttle requests per IP and simulate basic DDoS protection.

Are there any prerequisites?

You need an AWS account with the AWS CLI configured and some familiarity with S3, CloudFront, and IAM.

Before We Start

Before you touch the AWS console, let's lock in what you're about to secure and why a fintech company needs every one of these protection layers working together.

Set Up S3 with Encryption and Lockdown

The foundation of fintech data protection is making sure customer files can never leak via a misconfigured bucket. A single permission mistake on an S3 bucket can expose thousands of financial records to the public internet.

In this step, you will create the encrypted, locked-down storage layer for VaultPay. You will configure Block Public Access, enable versioning, and set SSE-KMS as the default encryption. Then you will prove the lockdown works by attempting to reach a file directly.

In this step, get ready to:
  • Create an encrypted S3 bucket with Block Public Access and versioning enabled.
  • Upload a sample customer records file to the bucket.
  • Prove the lockdown works by confirming direct access is denied.
Create the encrypted, locked-down S3 bucket

S3 bucket names must be globally unique across all AWS accounts. You will append a personal identifier to keep the name unique.

  • Create your S3 bucket by running this command:
aws s3api create-bucket --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]] --region us-east-1

Why us-east-1?

In a later step, you will attach AWS WAF to a CloudFront distribution. WAF resources scoped to CloudFront must be created in us-east-1. Keeping everything in the same region simplifies the setup.

You should see a Location value in the output confirming your bucket was created.

Seeing a BucketAlreadyExists error?

Someone else already owns that bucket name. Change your unique ID to something more specific (e.g. add random digits) and re-run the command.

help me fix a BucketAlreadyExists error when creating my S3 bucket

Now lock the bucket down so no object can ever be made public. Block Public Access has four settings that together prevent any public access path.

  • Enable Block Public Access on your bucket by running:
aws s3api put-public-access-block --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]] --public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

What are the four Block Public Access settings?

  • BlockPublicAcls prevents new public ACLs from being applied.
  • IgnorePublicAcls makes S3 ignore any existing public ACLs.
  • BlockPublicPolicy rejects bucket policies that grant public access.
  • RestrictPublicBuckets limits access to AWS-authorized principals only.
  • Confirm all four settings are active by running:
aws s3api get-public-access-block --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]]

You should see all four values set to true in the output.

For an extra layer of protection, also enable Block Public Access at the account level. This acts as a safety net across all buckets in your account.

  • Apply account-level Block Public Access by running:
aws s3control put-public-access-block --account-id [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]] --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

This is account-wide

Account-level Block Public Access applies to every bucket in your AWS account. If you have other buckets that intentionally serve public content (like a static website), skip this command and rely on the bucket-level setting you already applied.

Next, create a dedicated KMS key for encrypting VaultPay data. A customer-managed key gives you full control over key policies, which you will need when CloudFront requests objects from this bucket.

  • Create your KMS encryption key by running:
aws kms create-key --description "VaultPay S3 encryption key"

The output contains a KeyId field. Copy this value. You will use it in the next commands.

  • Create an alias for the key so it is easy to reference later:
aws kms create-alias --alias-name alias/vaultpay-s3-key --target-key-id [[KMS_KEY_ID="the KeyId from the previous output"]]

Now configure the bucket to use versioning and SSE-KMS encryption by default. Versioning protects against accidental overwrites or deletions of customer data.

  • Enable versioning on the bucket by running:
aws s3api put-bucket-versioning --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]] --versioning-configuration Status=Enabled
  • Set SSE-KMS as the default encryption using your new key:
aws s3api put-bucket-encryption --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]] --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"alias/vaultpay-s3-key"},"BucketKeyEnabled":true}]}'

What does BucketKeyEnabled do?

With a Bucket Key, S3 generates a short-lived data key from your KMS key and reuses it for multiple objects. This dramatically reduces the number of KMS API calls (and the cost) without weakening encryption.

  • Verify the encryption configuration by running:
aws s3api get-bucket-encryption --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]]

You should see aws:kms as the SSEAlgorithm and your key alias listed under KMSMasterKeyID.

Seeing an AccessDenied error on the encryption command?

Your IAM user or role needs kms:GenerateDataKey and kms:Decrypt permissions on the KMS key. If you created the key with the same credentials, you should have these by default through the key policy. Check that you copied the correct KeyId when creating the alias.

help me troubleshoot KMS AccessDenied when configuring S3 bucket encryption

The bucket is now encrypted and locked down. Time to upload a sample file to test with.

  • Create a sample customer records file by running:
cat > customer-records.json << 'EOF'
{
  "customers": [
    {
      "id": "CUST-001",
      "name": "Alice Johnson",
      "account_number": "4532-XXXX-XXXX-7891",
      "balance": 24500.00,
      "last_transaction": "2024-01-15"
    },
    {
      "id": "CUST-002",
      "name": "Bob Martinez",
      "account_number": "6011-XXXX-XXXX-3456",
      "balance": 18200.50,
      "last_transaction": "2024-01-14"
    }
  ]
}
EOF

What is this file?

This is simulated sensitive financial data representing customer records that VaultPay must protect. In a real deployment, this would be actual PII and financial data subject to regulations like PCI-DSS.

  • Upload the file to your bucket by running:
aws s3 cp customer-records.json s3://fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]]/
  • Confirm the file is in the bucket by running:
aws s3 ls s3://fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]]/

You should see customer-records.json listed with its size and upload timestamp.

Test direct access to prove the lockdown

The whole point of Block Public Access is to ensure no one can reach your objects through a direct S3 URL. Time to prove that the protection is working.

Before you run this test, what do you think will happen when you try to load the file through its direct S3 URL?

  • Attempt to access the file directly by running this curl command:
curl https://fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]].s3.us-east-1.amazonaws.com/customer-records.json

You will see an XML response containing AccessDenied. The file exists in the bucket, but no anonymous or public request can reach it. This is exactly the behavior you want for sensitive fintech data.

Why AccessDenied and not 404?

S3 returns AccessDenied (HTTP 403) rather than NoSuchKey because Block Public Access strips all public permissions before the request can even check if the object exists. This also prevents attackers from probing which objects exist in your bucket.

Got a different response?

If you see the actual JSON file contents, Block Public Access is not properly enabled. Re-run the get-public-access-block command above and confirm all four settings are true.

help me debug why my S3 bucket is returning file contents instead of AccessDenied

Now confirm the full encryption and lockdown state with the verification commands from the outline.

What do you expect to see in the encryption output?

  • Verify your bucket encryption by running:
aws s3api get-bucket-encryption --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]]
  • Verify Block Public Access by running:
aws s3api get-public-access-block --bucket fintech-vault-[[YOURID="your unique ID e.g. jsmith-2024"]]

The encryption output shows aws:kms as the algorithm with your KMS key. The public access block output shows all four settings as true. Your storage layer is fully protected.

Your S3 bucket is encrypted with a dedicated KMS key, versioned, and completely locked down from public access. Next up, you will create a CloudFront distribution with Origin Access Control so authenticated users can reach these files without ever opening the bucket to the internet.

Create CloudFront Distribution with OAC

Your S3 bucket is locked down. No one can reach the files directly. But VaultPay's application still needs to serve those customer records to authenticated users.

The solution is Origin Access Control (OAC). OAC lets CloudFront sign requests to S3 on your behalf, so the bucket stays fully private while CloudFront serves content to the world over HTTPS.

In this step, get ready to:
  • Create an Origin Access Control that signs every request to your S3 bucket.
  • Create a CloudFront distribution that uses the OAC to access your locked-down bucket.
  • Update the bucket policy so only your CloudFront distribution can read objects.
Create the Origin Access Control

An OAC tells CloudFront how to authenticate itself when talking to your S3 bucket. You will create one that always signs requests using AWS Signature Version 4.

  • Create the OAC by running this command in your terminal:
aws cloudfront create-origin-access-control \
  --origin-access-control-config Name="VaultPay-OAC",SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3

What does this command do?

  • SigningProtocol=sigv4 is the only valid signing protocol for OAC. It uses AWS Signature Version 4 to authenticate requests.
  • SigningBehavior=always means CloudFront signs every single request it sends to S3. This is the recommended setting.
  • OriginAccessControlOriginType=s3 tells CloudFront this OAC is designed for an S3 bucket origin.
  • In the output, find and copy the Id value. You will need this in the next command.
  • Store the OAC ID in a shell variable so you can reference it easily in later commands:
OAC_ID=your-oac-id-here

Getting an access denied error?

Make sure your AWS CLI user has cloudfront:CreateOriginAccessControl permission. If you are using an admin-level IAM user, this should already be in place.

Help me fix my IAM permissions for CloudFront OAC

Create the CloudFront distribution

Now you will create a CloudFront distribution that points at your S3 bucket and uses the OAC you just created. This uses the managed CachingOptimized cache policy, which is the AWS-recommended approach for S3 origins.

  • Create a file called distribution-config.json by running this command (replace the bucket name and OAC ID with your own values):
cat > distribution-config.json << EOF
{
  "CallerReference": "vaultpay-dist-$(date +%s)",
  "Origins": {
    "Quantity": 1,
    "Items": [{
      "Id": "S3-fintech-vault",
      "DomainName": "[[BUCKET_NAME="fintech-vault-<your-id>"]].s3.amazonaws.com",
      "S3OriginConfig": {"OriginAccessIdentity": ""},
      "OriginAccessControlId": "[[OAC_ID="your-oac-id"]]"
    }]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-fintech-vault",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]},
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
  },
  "Comment": "VaultPay CloudFront distribution",
  "Enabled": true
}
EOF

What is happening in this config?

  • S3OriginConfig.OriginAccessIdentity is set to an empty string because you are using the modern OAC, not the legacy Origin Access Identity (OAI).
  • OriginAccessControlId links your new OAC to this origin so CloudFront signs every request to S3.
  • CachePolicyId references the AWS managed CachingOptimized policy, which handles caching configuration for S3 origins.
  • ViewerProtocolPolicy: redirect-to-https ensures all viewer connections use HTTPS.
  • Create the distribution by running:
aws cloudfront create-distribution \
  --distribution-config file://distribution-config.json
  • In the output, find and save two values: the Distribution.Id (e.g. E1A2B3C4D5E6F7) and the Distribution.DomainName (e.g. d111111abcdef8.cloudfront.net).
  • Store them in shell variables for later use:
DISTRIBUTION_ID=your-distribution-id-here
DOMAIN_NAME=your-domain-name.cloudfront.net

The distribution is now created, but S3 does not yet trust it. You need to add a bucket policy that grants this specific distribution permission to read objects.

  • Get your AWS account ID by running:
aws sts get-caller-identity --query 'Account' --output text
  • Create a file called bucket-policy.json with the following policy (replace the bucket name, account ID, and distribution ID with your own values):
cat > bucket-policy.json << EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipalReadOnly",
    "Effect": "Allow",
    "Principal": {"Service": "cloudfront.amazonaws.com"},
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::[[BUCKET_NAME="fintech-vault-<your-id>"]]/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::[[ACCOUNT_ID="your-account-id"]]:distribution/[[DISTRIBUTION_ID="your-distribution-id"]]"
      }
    }
  }]
}
EOF

What does this bucket policy do?

This policy grants s3:GetObject permission to the cloudfront.amazonaws.com service principal. The Condition block restricts access so only YOUR specific CloudFront distribution (identified by its ARN) can read objects.

No other CloudFront distribution or AWS service can use this policy to access your bucket.

  • Apply the bucket policy by running:
aws s3api put-bucket-policy \
  --bucket [[BUCKET_NAME="fintech-vault-<your-id>"]] \
  --policy file://bucket-policy.json

The bucket policy is now in place. CloudFront can read your objects, but only through signed OAC requests.

What about the KMS key policy?

Your bucket uses SSE-KMS encryption. CloudFront needs permission on the KMS key to decrypt objects. If you used the default aws/s3 managed key, CloudFront already has the necessary access.

If you created a custom KMS key, you need to add a statement to the key policy granting cloudfront.amazonaws.com the kms:Decrypt and kms:GenerateDataKey* actions, conditioned on your distribution ARN.

Distribution creation failed?

  • Check that your distribution-config.json is valid JSON. A missing comma or bracket will cause a parsing error.
  • Confirm the OAC ID is correct by running aws cloudfront list-origin-access-controls and checking it matches what you pasted.
  • Make sure your bucket name in DomainName uses the format bucket-name.s3.amazonaws.com (not the website endpoint).

Help me troubleshoot my CloudFront distribution creation error

Wait for deployment and test access

CloudFront distributions take a few minutes to deploy across edge locations worldwide. You can monitor the status before testing.

  • Check the deployment status by running:
aws cloudfront get-distribution --id [[DISTRIBUTION_ID="your-distribution-id"]] \
  --query "Distribution.Status" --output text
  • Wait until the output changes from InProgress to Deployed. This typically takes 3-5 minutes. Re-run the command periodically to check.

Speed up the wait

You can use the built-in AWS CLI waiter to block until deployment completes:

aws cloudfront wait distribution-deployed --id [[DISTRIBUTION_ID="your-distribution-id"]]

This command returns silently once the distribution is fully deployed.

Before you test, think about this: what do you expect to happen when you request the file through CloudFront versus directly through S3?

  • Once the status shows Deployed, test access through CloudFront by running:
curl https://[[DOMAIN_NAME="your-domain.cloudfront.net"]]/customer-records.json

You should see the contents of your customer-records.json file displayed in the terminal. CloudFront successfully retrieved the encrypted file from S3 on your behalf.

  • Now confirm the S3 direct URL still blocks access by running:
curl https://[[BUCKET_NAME="fintech-vault-<your-id>"]].s3.amazonaws.com/customer-records.json

You should see an Access Denied XML response. The bucket is still fully locked down. The only path to your data is through CloudFront.

CloudFront returning Access Denied too?

  • Double-check the distribution ID in your bucket policy matches the actual distribution. Run aws cloudfront list-distributions --query "DistributionList.Items[0].Id" to confirm.
  • If you used a custom KMS key (not aws/s3), you must update the KMS key policy to grant cloudfront.amazonaws.com decrypt permissions.
  • Make sure the distribution is fully deployed (status Deployed, not InProgress).

Help me troubleshoot my CloudFront Access Denied error with OAC and SSE-KMS

Your content delivery layer is live. CloudFront is the single gateway to your encrypted customer data, and direct S3 access remains blocked. Next up, you will discover what happens when an attacker sends malicious payloads through that gateway.

Expose the Vulnerability Without WAF

Your CloudFront distribution with OAC ensures files are only accessible through the CDN, never directly from S3. But CloudFront is a content delivery network. It accelerates and routes requests without inspecting their contents.

A SQL injection string or cross-site scripting payload embedded in a request passes through CloudFront completely untouched. In this step, you'll prove this gap exists by sending simulated attacks and watching them succeed.

In this step, get ready to:
  • Send a SQL injection payload through CloudFront and confirm it returns HTTP 200.
  • Send a cross-site scripting payload and confirm it also returns HTTP 200.
  • Document the attack surface that exists without application-layer filtering.
Send a SQL injection payload through CloudFront

SQL injection is one of the most common web attacks. An attacker appends database query fragments to URL parameters, hoping a downstream system will execute them. If nothing between the user and the backend inspects the request, the payload reaches the origin untouched.

Before you run this command, think about what you expect. CloudFront delivered your file successfully in the previous step. Do you think it will block a request that contains SQL syntax in the query string, or let it through?

  • Test whether CloudFront inspects query string payloads by running this command:
curl -I "https://[[CF_DOMAIN="d1234abcdef.cloudfront.net"]]/customer-records.json?id=1' OR '1'='1"

You'll see the first line of the response is HTTP/2 200. The SQL injection payload sailed through without any inspection.

What does this prove?

CloudFront treated the request like any other. The ?id=1' OR '1'='1 query string reached S3 as-is. S3 ignored it (since it's just serving a static object), but if a database-backed API were behind this distribution, that payload could extract unauthorized data.

Seeing a different status code?

If you see HTTP/2 403, double-check that your CloudFront distribution does not already have a WAF Web ACL associated with it. You can verify in the AWS console under CloudFront > Distributions > your distribution > Security tab.

If you see a connection error, confirm the distribution status is Deployed and try again after a minute.

help me troubleshoot why my CloudFront distribution is not returning HTTP 200 for a request with a SQL injection query string

Send an XSS payload in a request header

Cross-site scripting (XSS) attacks inject malicious scripts into web responses. Attackers often embed these in cookies, query parameters, or custom headers. A secure edge layer should reject requests containing script tags before they reach the application.

  • Send a request with a cross-site scripting payload in the Cookie header by running:
curl -I -H "Cookie: session=<script>alert('xss')</script>" "https://[[CF_DOMAIN="d1234abcdef.cloudfront.net"]]/customer-records.json"

Once again, the response starts with HTTP/2 200. CloudFront forwarded the XSS-laden cookie without question.

Why the Cookie header?

Cookies are a common attack vector because they're sent automatically with every request. An attacker who can inject a malicious cookie value targets any downstream system that reads session data without sanitizing it.

In the next step, you'll add a managed rule group that specifically inspects cookie values for XSS patterns.

Understand the attack surface

Both attacks returned HTTP 200. CloudFront delivered the file regardless of what the request contained.

What is the attack surface here?

Any request that reaches your origin could carry malicious payloads targeting downstream systems that consume this data. A SQL injection in a query parameter could exploit a backend API. An XSS payload in a cookie could compromise user sessions.

CloudFront accelerates delivery but performs zero payload inspection. Without an application-layer filter, your distribution is an open door for crafted attacks. The fix is AWS WAF, which inspects every request at the edge before it reaches your origin.

You've confirmed the gap: CloudFront without WAF is a wide-open pipe for application-layer attacks. Next up, you'll deploy AWS WAF with managed rule groups to turn those 200s into 403s.

Shield the Distribution with AWS WAF

In the previous step, you proved that SQL injection and XSS payloads sail through CloudFront completely uninspected. CloudFront delivers content, but it has no capability to examine request payloads for malicious patterns.

AWS WAF solves this gap. It inspects every request at the CloudFront edge and blocks known attack patterns before they reach your origin. In this step, you will deploy a Web ACL with managed rule groups that turn those 200 responses into 403 Forbidden.

In this step, get ready to:
  • Create a WAF Web ACL with SQL injection and XSS managed rule groups.
  • Associate the Web ACL with your CloudFront distribution.
  • Re-test the same attack payloads and confirm they are blocked with HTTP 403.
Create the WAF rules file

WAF rules are defined as JSON. You will create a local file containing two managed rule groups, then reference it in the create command.

  • Create a file called waf-rules.json on your Desktop by running:
cat > ~/Desktop/waf-rules.json << 'EOF'
[
  {
    "Name": "AWS-AWSManagedRulesSQLiRuleSet",
    "Priority": 0,
    "Statement": {
      "ManagedRuleGroupStatement": {
        "VendorName": "AWS",
        "Name": "AWSManagedRulesSQLiRuleSet"
      }
    },
    "OverrideAction": { "None": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "AWSManagedRulesSQLiRuleSet"
    }
  },
  {
    "Name": "AWS-AWSManagedRulesCommonRuleSet",
    "Priority": 1,
    "Statement": {
      "ManagedRuleGroupStatement": {
        "VendorName": "AWS",
        "Name": "AWSManagedRulesCommonRuleSet"
      }
    },
    "OverrideAction": { "None": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "AWSManagedRulesCommonRuleSet"
    }
  }
]
EOF

What does this rules file define?

  • AWSManagedRulesSQLiRuleSet (priority 0) catches SQL injection patterns in query strings, headers, and body content.
  • AWSManagedRulesCommonRuleSet (priority 1) provides OWASP Top 10 coverage including cross-site scripting rules like CrossSiteScripting_BODY and CrossSiteScripting_COOKIE.
  • OverrideAction is set to None so the rule group's own actions (Block) apply. Managed rule groups use OverrideAction instead of Action.
  • Confirm the file was created by running:
cat ~/Desktop/waf-rules.json

You should see the full JSON with both rule group definitions printed to your terminal.

Create the Web ACL

CloudFront-scoped WAF resources must be created in us-east-1 regardless of where your other resources live.

  • Create the Web ACL with both managed rule groups by running:
aws wafv2 create-web-acl \
    --name fintech-vault-waf \
    --scope CLOUDFRONT \
    --default-action Allow={} \
    --rules file://~/Desktop/waf-rules.json \
    --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=fintechVaultWAF \
    --region us-east-1

What does this command do?

  • --scope CLOUDFRONT tells WAF this ACL protects a CloudFront distribution.
  • --default-action Allow={} means requests that do not match any rule pass through. Only flagged requests get blocked.
  • --visibility-config enables CloudWatch metrics and sampled request logging so you can inspect blocked traffic later.
  • --region us-east-1 is required for all CloudFront-scoped WAF resources.

The command returns a JSON response containing the Web ACL's ARN and ID.

  • Copy the ARN value from the response. You will need it in the next command.

Seeing a WAFLimitsExceededException?

This means you have reached the maximum number of Web ACLs in your account for this scope. Delete an unused Web ACL first, then retry.

help me troubleshoot a WAFLimitsExceededException when creating a CloudFront-scoped Web ACL

Associate the Web ACL with CloudFront

Creating the Web ACL defines the rules, but they do not inspect traffic until you attach the ACL to a resource.

  • Associate the Web ACL with your CloudFront distribution by running:
aws wafv2 associate-web-acl \
    --web-acl-arn [[WAF_ACL_ARN="the ARN from the create-web-acl output"]] \
    --resource-arn [[CF_DISTRIBUTION_ARN="arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"]] \
    --region us-east-1

Where do I find the distribution ARN?

Your CloudFront distribution ARN follows the pattern arn:aws:cloudfront::<account-id>:distribution/<distribution-id>. You can find your distribution ID by running aws cloudfront list-distributions --query "DistributionList.Items[].{Id:Id,Domain:DomainName}".

The command returns no output on success. This means WAF is now inspecting all traffic to your distribution.

Seeing a WAFInvalidParameterException?

Double check that both ARNs are correct. The Web ACL ARN must start with arn:aws:wafv2:us-east-1 and the distribution ARN must start with arn:aws:cloudfront::.

help me fix a WAFInvalidParameterException when associating a Web ACL with CloudFront

Verify WAF blocks the attack payloads

Before you run the next command, predict what you expect to see. In the previous step, the same SQL injection payload returned HTTP 200. What should happen now that WAF is active?

  • Re-run the SQL injection test against your CloudFront URL:
curl -I "https://[[CF_DOMAIN="your-distribution-domain.cloudfront.net"]]/customer-records.json?id=1'%20OR%20'1'%3D'1"

You should see HTTP 403 Forbidden in the response. WAF recognized the SQL injection pattern in the query string and blocked the request before it reached S3.

  • Now test with an XSS payload in a request header:
curl -I -H "X-Custom: <script>alert('xss')</script>" "https://[[CF_DOMAIN="your-distribution-domain.cloudfront.net"]]/customer-records.json"

This also returns HTTP 403 Forbidden. The AWSManagedRulesCommonRuleSet detected the cross-site scripting payload and blocked it.

What changed between Step 3 and now?

In Step 3, both payloads returned HTTP 200 because CloudFront had no inspection capability. Now the same requests return HTTP 403 because WAF evaluates every request against the managed rule groups at the edge.

The default action is Allow, so legitimate requests still pass through. Only requests matching SQL injection or XSS signatures get blocked.

  • Confirm a clean request still works by running:
curl -I "https://[[CF_DOMAIN="your-distribution-domain.cloudfront.net"]]/customer-records.json"

You should see HTTP 200 OK. Legitimate traffic passes through unaffected.

  • Verify the blocked requests appear in WAF sampled requests by opening the AWS Console.
  • Navigate to the WAF & Shield service, then select Web ACLs.
  • Make sure the region dropdown shows Global (CloudFront).
  • Click fintech-vault-waf, then select the Sampled requests tab.

You should see your blocked requests listed with the rule group that triggered the block. This confirms WAF is actively protecting your distribution.

Still seeing HTTP 200 on the attack payloads?

WAF association can take a minute or two to propagate. Wait 60 seconds and retry the curl commands.

If it still returns 200 after waiting, verify the association by running:

aws wafv2 get-web-acl-for-resource \
    --resource-arn [[CF_DISTRIBUTION_ARN="arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"]] \
    --region us-east-1

The output should show your fintech-vault-waf Web ACL. If it returns nothing, re-run the associate command.

help me troubleshoot why WAF is not blocking SQL injection payloads after associating a Web ACL with CloudFront

Your CloudFront distribution is now shielded at the application layer. Next up, you will lock down the database tier with encryption and network isolation.

Launch Encrypted RDS with Network Isolation

Your S3 files are locked behind CloudFront and shielded by WAF. The next layer to secure is the database tier, where VaultPay stores its most sensitive financial records.

A production database needs three protections working together: encryption at rest (protects against physical media theft), encryption in transit (protects data on the wire), and network isolation (ensures only the application tier can connect). In this step, you'll build all three using RDS, VPC security groups, and KMS.

In this step, get ready to:
  • Create security groups that restrict database access to the application tier only.
  • Launch an encrypted RDS PostgreSQL instance in a private subnet with SSL enforcement.
  • Verify that non-SSL connections are refused and SSL connections succeed.
Create security groups and a DB subnet group

Network isolation starts with security groups. You need two: one for the application tier (which will eventually connect to the database) and one for the database itself that only accepts connections from the app tier.

  • Get your default VPC ID by running this command:
aws ec2 describe-vpcs \
  --filters Name=isDefault,Values=true \
  --query "Vpcs[0].VpcId" \
  --output text
  • Copy the VPC ID from the output (it looks like vpc-0abc123def456).
  • Create the application-tier security group by running:
aws ec2 create-security-group \
  --group-name fintech-app-sg \
  --description "VaultPay application tier" \
  --vpc-id [[VPC_ID="your VPC ID from above"]]
  • Copy the GroupId from the output (it looks like sg-0abc123...). This is your app security group ID.
  • Create the database security group by running:
aws ec2 create-security-group \
  --group-name fintech-db-sg \
  --description "VaultPay database tier" \
  --vpc-id [[VPC_ID="your VPC ID from above"]]
  • Copy the GroupId from this output. This is your DB security group ID.
  • Add an inbound rule to fintech-db-sg allowing port 3306 only from fintech-app-sg:
aws ec2 authorize-security-group-ingress \
  --group-id [[DB_SG_ID="your fintech-db-sg GroupId"]] \
  --protocol tcp \
  --port 3306 \
  --source-group [[APP_SG_ID="your fintech-app-sg GroupId"]]

What does this inbound rule do?

This rule says: only instances tagged with fintech-app-sg can reach port 3306 on the database. Any other IP address or security group is blocked.

This is the network isolation layer. Even if someone compromised an EC2 instance in a different security group, they could not reach the database.

  • Verify the rule was added by running:
aws ec2 describe-security-groups \
  --group-ids [[DB_SG_ID="your fintech-db-sg GroupId"]] \
  --query "SecurityGroups[0].IpPermissions"

You should see an entry showing FromPort: 3306 with a UserIdGroupPairs reference to your app security group.

Next, you need a DB subnet group. This tells RDS which subnets to place the database in.

  • List the subnets in your default VPC by running:
aws ec2 describe-subnets \
  --filters Name=vpc-id,Values=[[VPC_ID="your VPC ID"]] \
  --query "Subnets[*].[SubnetId,AvailabilityZone]" \
  --output table
  • Pick two subnet IDs from different Availability Zones. You need at least two for a DB subnet group.
  • Create the DB subnet group by running:
aws rds create-db-subnet-group \
  --db-subnet-group-name fintech-vault-subnet-group \
  --db-subnet-group-description "VaultPay database subnets" \
  --subnet-ids [[SUBNET_1="first subnet ID"]] [[SUBNET_2="second subnet ID"]]

You should see output confirming the subnet group with status Complete.

Seeing an error about availability zones?

The create-db-subnet-group command requires subnets in at least two different Availability Zones. Make sure the two subnet IDs you picked are from different AZs (e.g. us-east-1a and us-east-1b).

Help me troubleshoot my DB subnet group error.

Create a parameter group and launch the RDS instance

The rds.force_ssl parameter forces every connection to use SSL/TLS encryption. You cannot set this on a default parameter group, so you need a custom one.

  • Create a custom DB parameter group by running:
aws rds create-db-parameter-group \
  --db-parameter-group-name fintech-vault-pg-params \
  --db-parameter-group-family postgres16 \
  --description "VaultPay PostgreSQL params with forced SSL"
  • Set rds.force_ssl to 1 in the new parameter group:
aws rds modify-db-parameter-group \
  --db-parameter-group-name fintech-vault-pg-params \
  --parameters "ParameterName='rds.force_ssl',ParameterValue='1',ApplyMethod=immediate"

What is rds.force_ssl?

When rds.force_ssl is set to 1, the database rejects any connection that does not use SSL/TLS. This protects data in transit between the application and the database.

Without this setting, a misconfigured application could accidentally send credentials and queries in plain text over the network.

  • Now launch the RDS instance with --storage-encrypted for encryption at rest, --no-publicly-accessible for network isolation, the custom parameter group, and the database security group:
aws rds create-db-instance \
  --db-instance-identifier fintech-vault-db \
  --db-instance-class db.t3.micro \
  --engine postgres \
  --engine-version 16 \
  --master-username vaultadmin \
  --master-user-password your-secure-password-here \
  --allocated-storage 20 \
  --storage-encrypted \
  --vpc-security-group-ids [[DB_SG_ID="your fintech-db-sg GroupId"]] \
  --db-subnet-group-name fintech-vault-subnet-group \
  --db-parameter-group-name fintech-vault-pg-params \
  --no-publicly-accessible \
  --no-multi-az

What do these flags do?

  • --storage-encrypted enables encryption at rest using KMS. If you omit --kms-key-id, RDS uses the default aws/rds KMS key.
  • --no-publicly-accessible ensures the instance has no public IP. Only resources inside the VPC can reach it.
  • --db-parameter-group-name attaches the SSL-enforcing parameter group from the start.
  • --vpc-security-group-ids locks network access to the fintech-db-sg rules you created earlier.

RDS creation takes 5-10 minutes

The instance needs time to provision. Wait for it to reach the available state before proceeding. You can monitor progress with the command below.

  • Wait for the instance to become available by running:
aws rds wait db-instance-available \
  --db-instance-identifier fintech-vault-db

This command blocks until the instance is ready. It returns silently when complete.

Verify encryption and test SSL enforcement

Three things must be true: the storage must be encrypted, a non-SSL connection must fail, and an SSL connection must succeed.

  • Confirm storage encryption is enabled by running:
aws rds describe-db-instances \
  --db-instance-identifier fintech-vault-db \
  --query "DBInstances[0].{Encrypted:StorageEncrypted,Endpoint:Endpoint.Address}"

You should see Encrypted: true along with the database endpoint address. Copy the endpoint for the connection tests below.

Now test SSL enforcement. What do you think will happen if you try to connect without SSL?

  • Attempt a non-SSL connection by running:
PGSSLMODE=disable psql \
  -h [[DB_ENDPOINT="your RDS endpoint address"]] \
  -U vaultadmin \
  -d postgres

The connection is refused. You will see an error similar to this:

psql: error: FATAL: no pg_hba.conf entry for host "...", user "vaultadmin", database "postgres", SSL off

Why does this error appear?

When rds.force_ssl=1, RDS modifies the internal pg_hba.conf to reject any non-SSL connection. The error confirms that SSL enforcement is active.

  • Download the RDS certificate bundle by running:
wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
  • Connect using the certificate bundle to verify SSL works:
psql "host=[[DB_ENDPOINT="your RDS endpoint address"]] port=5432 dbname=postgres user=vaultadmin sslmode=verify-full sslrootcert=global-bundle.pem"

Enter the master password when prompted. You should see the psql prompt confirming a TLS-encrypted connection.

  • Type \q to exit psql.

Connection timed out?

If the SSL connection also fails with a timeout, the issue is network access. Since the instance is not publicly accessible, you must connect from a resource inside the same VPC (like an EC2 instance in fintech-app-sg). For testing purposes, you can temporarily add an inbound rule to fintech-db-sg allowing your IP on port 5432. Remember to remove it after testing.

Help me troubleshoot my RDS connection issue.

Your fintech data stack is now fully protected. Customer files in S3 are encrypted and only reachable through WAF-protected CloudFront. The database is encrypted at rest, enforces SSL for every connection, and only accepts traffic from the application security group.

Secret mission

Rate-Based DDoS Throttling

Your WAF blocks malicious payloads, but what about a flood of legitimate-looking requests? A volumetric attack can overwhelm your application without triggering any pattern-based rule. In this secret mission, you will add a rate-based WAF rule that caps requests per IP and simulate a basic DDoS burst to prove it works.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Several resources in this project incur hourly charges, so deleting same-day keeps costs minimal.

Cost warning

The AWS WAF Web ACL costs $5.00/month per ACL plus $1.00/month per rule, prorated hourly. The KMS key costs $1.00/month, also prorated. If you leave these running for a full month, expect roughly $6-8 in charges. Deleting today keeps total costs under $1-2.

Resources you used:

  • S3 bucket (fintech-vault-<your-id>) with customer-records.json.
  • CloudFront distribution with Origin Access Control.
  • AWS WAF Web ACL (fintech-vault-waf) with managed rule groups and rate-based rule.
  • KMS key used for SSE-KMS encryption.
  • RDS instance (fintech-vault-db) with custom parameter group and DB subnet group.
  • Security groups (fintech-app-sg and fintech-db-sg).

Keep everything running

No action needed. Choose this if you plan to build on this security stack further. Expect ongoing costs of approximately $6-8/month from WAF and KMS charges. The RDS db.t3.micro instance is free-tier eligible for 12 months.

Pause - I'll come back to this later

Stop the RDS instance to avoid compute charges while keeping your data intact. WAF and KMS charges still apply while active.

  • Stop the RDS instance to pause compute charges by running:
aws rds stop-db-instance --db-instance-identifier fintech-vault-db

What does stopping do?

Stopping the instance halts compute charges but storage charges still apply. The instance automatically restarts after 7 days. You can stop it again if you need more time.

Delete - I don't want to use this again

Remove all project resources completely. Run the following commands in order. Some resources depend on others being removed first.

Delete the RDS instance first (this takes a few minutes):

  • Delete the RDS instance by running:
aws rds delete-db-instance \
    --db-instance-identifier fintech-vault-db \
    --skip-final-snapshot
  • Wait for the instance to finish deleting before continuing. You can check the status by running:
aws rds describe-db-instances \
    --db-instance-identifier fintech-vault-db \
    --query "DBInstances[0].DBInstanceStatus" \
    --output text

Once the instance is deleted (the describe command returns an error saying the instance is not found), continue with the remaining resources.

Delete the RDS parameter group and subnet group:

  • Delete the custom DB parameter group by running:
aws rds delete-db-parameter-group \
    --db-parameter-group-name fintech-vault-params
  • Delete the DB subnet group by running:
aws rds delete-db-subnet-group \
    --db-subnet-group-name fintech-vault-subnet-group

Delete the security groups:

  • Delete the database security group first (it references the app security group, so order matters). Get the group IDs and delete by running:
aws ec2 delete-security-group --group-id <fintech-db-sg-id>
aws ec2 delete-security-group --group-id <fintech-app-sg-id>

How do I find my security group IDs?

Run aws ec2 describe-security-groups --filters "Name=group-name,Values=fintech-*" --query "SecurityGroups[*].{Name:GroupName,ID:GroupId}" --output table to list your security group IDs.

Disable and delete the CloudFront distribution:

  • You must disable the distribution before deleting it. First, get the current ETag and save a disabled config:
DISTRIBUTION_ID=<your-distribution-id>
ETAG=$(aws cloudfront get-distribution-config --id $DISTRIBUTION_ID --query 'ETag' --output text)
aws cloudfront get-distribution-config --id $DISTRIBUTION_ID | \
    jq '.DistributionConfig.Enabled = false | .DistributionConfig.WebACLId = ""' > temp_disabled_config.json
aws cloudfront update-distribution \
    --id $DISTRIBUTION_ID \
    --distribution-config file://<(jq '.DistributionConfig' temp_disabled_config.json) \
    --if-match $ETAG
  • Wait for the distribution to finish deploying:
aws cloudfront wait distribution-deployed --id $DISTRIBUTION_ID
  • Get the new ETag and delete the distribution:
ETAG=$(aws cloudfront get-distribution-config --id $DISTRIBUTION_ID --query 'ETag' --output text)
aws cloudfront delete-distribution --id $DISTRIBUTION_ID --if-match $ETAG

Delete the CloudFront OAC:

  • Get the OAC ID and ETag, then delete it:
OAC_ID=<your-oac-id>
OAC_ETAG=$(aws cloudfront get-origin-access-control --id $OAC_ID --query 'ETag' --output text)
aws cloudfront delete-origin-access-control --id $OAC_ID --if-match $OAC_ETAG

Delete the WAF Web ACL:

  • Get the Web ACL ID and lock token, then delete:
aws wafv2 list-web-acls --scope CLOUDFRONT --region us-east-1
  • Note the Id and LockToken from the output, then run:
aws wafv2 delete-web-acl \
    --name fintech-vault-waf \
    --scope CLOUDFRONT \
    --id <web-acl-id> \
    --lock-token <lock-token> \
    --region us-east-1

Delete the S3 bucket:

  • Remove the bucket and all its contents by running:
aws s3 rb s3://fintech-vault-<your-id> --force

Schedule KMS key deletion:

  • Schedule the KMS key for deletion with the minimum 7-day waiting period:
aws kms schedule-key-deletion \
    --key-id <your-kms-key-id> \
    --pending-window-in-days 7

Why a 7-day wait?

KMS requires a minimum 7-day waiting period before permanently deleting a key. This gives you time to cancel with aws kms cancel-key-deletion if you change your mind. After 7 days, the key and all data encrypted with it become permanently unrecoverable.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-grade security stack for VaultPay, a fictional fintech company, using multiple layers of AWS security controls working together to protect sensitive customer data.

Here's what you learned:

  • Locked down an S3 bucket with Block Public Access, versioning, and SSE-KMS encryption, then made it accessible only through a CloudFront distribution with Origin Access Control.
  • Proved that application-layer attacks pass through CloudFront untouched, then deployed AWS WAF with managed rule groups to block SQL injection and XSS payloads, turning 200s into 403s.
  • Launched an encrypted RDS instance in a private subnet, enforced SSL with rds.force_ssl, and restricted network access via security groups.
  • Secret Mission: Added a rate-based WAF rule to throttle volumetric attacks per IP, providing a first layer of DDoS defense.

Ready to quiz yourself?