Skip to content

Bruno ArrudaCloud Security

LabSecure Architecture

Building and Securing a Production Static Website on AWS

Hands-on AWS cloud security case study using CloudFront, private S3 with OAC, DNSSEC, CSP, monitoring, and keyless GitHub OIDC deployment.

AWS services
Route 53 · CloudFront · S3 · WAF · CloudWatch · IAM
Security focus
Private Origin · DNSSEC · CSP · Monitoring · OIDC · Least Privilege
Bruno ArrudaCloud Security & Infrastructure Professional

Published on 15 min read

On this page

Overview

I built this website as more than a place to publish technical content. I also wanted to use the infrastructure behind it as a practical cloud security project.

The application is a static website built with Astro, but the security requirements go beyond uploading static files to an S3 bucket. The objective was to design a production-oriented AWS architecture that keeps the origin private, serves content securely through the edge, and protects DNS and web traffic. It also provides monitoring and auditability and supports automated deployments without storing long-lived AWS credentials in GitHub.

The final solution uses Amazon Route 53, CloudFront, S3, ACM, AWS WAF, CloudWatch, SNS, CloudTrail, IAM, STS and GitHub Actions. More importantly, this Lab records why each security decision was made, how the control was validated, and which trade-offs were accepted.

Problem → Threat → Decision → Implementation → Validation → Trade-off

This reasoning model appears once because it guides the case study. It is not a set of labels to repeat around every section.

Security Requirements

The requirements became the basis of the architecture. Security was not added as a final deployment step.

Objective Required outcome
Origin protection Keep the S3 origin private
Controlled origin access Allow CloudFront to reach S3 through OAC
Transport security Redirect HTTP to HTTPS
Modern TLS Serve a valid ACM certificate through CloudFront
DNS integrity Sign the Route 53 hosted zone with DNSSEC
Browser security Apply CSP and other response headers
Web protection without premature blocking Observe requests with AWS WAF before enforcement
Detection Monitor CloudFront 4xx and 5xx rates
Alerting Deliver alarm notifications through SNS
Auditability Record AWS management events with CloudTrail
No long-lived GitHub AWS credentials Use GitHub OIDC and AWS STS
Least-privilege deployment Limit the deployment role to required actions
CSP deployment safety Validate the approved inline-script hash in CI
Cost awareness Keep AWS Budgets monitoring active

Threat Model

A static website has a relatively small application attack surface. This project therefore focuses mainly on infrastructure, browser controls, operational visibility, and the deployment path.

Threat or failure mode Control
Direct access to the storage origin Private S3 and OAC
Accidental public bucket exposure S3 Block Public Access
Traffic interception or downgrade HTTPS, ACM and CloudFront
DNS manipulation Route 53 and DNSSEC
Script injection impact Restrictive CSP
Clickjacking frame-ancestors and X-Frame-Options
MIME-type confusion X-Content-Type-Options
Browser capability abuse Permissions-Policy
Suspicious web requests AWS WAF
HTTP errors CloudWatch
Missed operational events SNS
Unauthorized AWS management changes CloudTrail
Compromised long-lived CI credentials GitHub OIDC and STS
Excessive deployment permissions Least-privilege IAM role
CSP regression SHA-256 validation in CI
Unexpected spending AWS Budgets

The threat model helps prevent unnecessary complexity. A control should address a relevant risk or operational requirement.

Architecture

The design separates public content delivery from deployment identity.

Public delivery: Internet → Route 53 with DNSSEC → CloudFront → OAC → private S3

Deployment identity: GitHub Actions → GitHub OIDC → AWS STS → least-privilege IAM role → S3 deployment and CloudFront invalidation

CloudWatch and SNS provide operational monitoring. CloudTrail records management events, while AWS Budgets provides cost awareness.

AWS security architecture showing Route 53 with DNSSEC, CloudFront with TLS, WAF and security headers, Origin Access Control to a private S3 bucket, CloudWatch and CloudTrail monitoring, and GitHub Actions deployment through OIDC, STS and a least-privilege IAM role.
Figure 1. Final security architecture for brunoarruda.com, including the public delivery path, private S3 origin, monitoring controls, and keyless GitHub OIDC deployment flow.

The main trust boundaries are the public edge, the private storage origin, and the temporary deployment session. CloudFront accepts public requests, OAC controls origin access, and STS issues short-lived credentials only after the GitHub identity satisfies the role trust policy.

Three principles guided the architecture:

  1. Private by default. Storage does not become public just because the website is public.
  2. Temporary identity over stored credentials. The deployment obtains a short-lived session when it needs one.
  3. Validate controls, not configurations. A configured control is tested through the behavior it should produce.

Building the Security Architecture

Private Origin: S3, CloudFront and OAC

The website must be public, but its storage origin does not need to be public.

The decision was to make CloudFront the only public entry point. S3 Block Public Access is enabled, static website hosting is disabled, ACLs are disabled through Bucket owner enforced, versioning is enabled, and objects use SSE-S3. CloudFront uses the S3 REST origin with Origin Access Control.

The bucket policy is restricted to the CloudFront distribution. This focused example shows the relationship without exposing account details:

{
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::<BUCKET_NAME>/*",
  "Condition": { "StringEquals": {
    "AWS:SourceArn": "arn:aws:cloudfront::<AWS_ACCOUNT_ID>:distribution/<DISTRIBUTION_ID>"
  } }
}

Direct origin access was tested with:

curl -I https://s3.eu-west-1.amazonaws.com/<BUCKET_NAME>/index.html

The observed result was HTTP/1.1 403 Forbidden, while CloudFront continued to serve the website successfully. PASS: the tested direct origin path was not public. CloudFront is public. The origin remains private.

The S3 website endpoint is not used. Directory-style routing is therefore handled at the edge.

DNS and Transport Security

The request path is: User → DNS resolution with DNSSEC → CloudFront → TLS with ACM → website.

Route 53 provides authoritative DNS and signs the hosted zone with the configured key-signing key, which is backed by AWS KMS. A DS record in the parent zone completes the DNSSEC chain of trust. The ACM viewer certificate for the apex and www names is in us-east-1, and CloudFront redirects HTTP to HTTPS. Route 53 resolves the www hostname to CloudFront; the existing CloudFront Function performs the HTTP redirect to the apex domain. Existing email records and existing subdomain behavior were preserved during the DNS migration.

DNSSEC validation used a public validating resolver:

dig @1.1.1.1 brunoarruda.com A +dnssec

The response included the authenticated-data ad flag, showing that this validating resolver authenticated that response. It does not mean DNS is universally immune to spoofing or misconfiguration. An HTTP request also confirmed the transport redirect:

curl -I http://brunoarruda.com/

The request redirected to HTTPS, and the external TLS assessment received grade A.

HSTS intentionally does not use includeSubDomains because enabling HSTS for all subdomains would break an existing HTTP-dependent subdomain.

Edge and Browser Security

HTTPS protects data in transit, but it does not control how a browser executes scripts, embeds pages, interprets content types, or exposes optional capabilities.

The response policy combines AWS Managed SecurityHeadersPolicy with a CloudFront viewer-response function. Together they provide X-Frame-Options, Referrer-Policy, X-Content-Type-Options, HSTS, Content Security Policy, and Permissions-Policy.

The initial CSP allowed script-src 'self' 'unsafe-inline'. The Astro production build was then inspected. The generated home page contained one required executable inline script, used for theme initialization. That understood script was authorized with a SHA-256 hash:

script-src 'self' 'sha256-<APPROVED_HASH>'

The policy still allows style-src 'unsafe-inline' because the current site has legitimate inline-style requirements. This is a documented scope choice, not a claim that inline styles are ideal in every application.

Validation covered the theme toggle, theme persistence, search, navigation, hard refresh, and the browser console. No functional CSP errors were observed. The external SecurityHeaders assessment improved from F to A+.

Web Protection with AWS WAF

The public edge needs visibility into suspicious requests. AWS WAF was therefore introduced in monitor mode before enforcement.

Traffic spikes were visible, but the available telemetry did not provide enough evidence to classify them as malicious. An unusual traffic pattern is an investigation signal, not automatically evidence of malicious activity.

Monitor mode does not automatically block requests. This is a deliberate baseline strategy for the current stage.

Detection, Monitoring and Auditability

CloudWatch and SNS

CloudWatch monitors error rates so operational failures become visible. The 4xx alarm uses the Average statistic over 5-minute periods, a threshold above 20%, three of three evaluation periods, and missing data treated as not breaching. The 5xx alarm uses Average over 5-minute periods, a threshold above 5%, two of two evaluation periods, and the same missing-data treatment.

SNS delivers email notifications without publishing the recipient address. A real 4xx alarm occurred and a real SNS notification was received, validating this chain:

CloudFront → CloudWatch metric → alarm threshold → alarm state → SNS → notification

AWS CloudWatch 4xxErrorRate alarm showing production traffic exceeding the configured 20 percent threshold, alarm state transitions, and successful SNS notification actions.
Figure 2. CloudWatch 4xx monitoring in production. The alarm entered the ALARM state after the configured threshold was exceeded and executed the SNS notification action before returning to OK.

WAF telemetry did not provide enough evidence to classify the event as an attack. Detection should trigger investigation, not automatically determine the cause.

Percentage alarms can be noisy on low-traffic websites. Their thresholds should be tuned with production data.

CloudTrail

CloudTrail answers a different question: What management activity occurred in the AWS account?

The implementation uses a multi-region trail, management read and write events, log-file validation, S3 log delivery, and a 180-day S3 lifecycle expiration window. Management-event log delivery to S3 was confirmed. CloudTrail log-file validation is enabled, allowing delivered log files to be checked for integrity using digest files; this Lab does not claim that an integrity-validation operation such as validate-logs was executed.

This Lab does not claim to collect every possible event. S3 data events, CloudTrail Insights, CloudWatch Logs integration, and an additional customer-managed KMS layer for CloudTrail logs are not enabled because they were outside the current requirement.

AWS Budgets

Cost awareness is an operational control. Existing monthly budget monitoring was already active and healthy, without publishing unnecessary account-specific values.

The budgets are account-wide rather than dedicated only to this website. That coverage is sufficient for the current scale.

Secure CI/CD with GitHub OIDC

OIDC and Temporary AWS Credentials

Permanent AWS access keys stored in GitHub can be exposed, reused, or granted more access than a deployment needs.

The flow is GitHub Actions → OIDC token → AWS IAM OIDC provider → AWS STS → dedicated deployment role → temporary credentials. The expected audience is sts.amazonaws.com.

The trust relationship is restricted to the approved repository identity and the main branch. The subject format observed for this repository and account configuration is represented with sanitized placeholders here:

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": { "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:brunodearruda@<OWNER_ID>/brunoarruda.com@<REPOSITORY_ID>:ref:refs/heads/main"
  } }
}

Least-Privilege Deployment Role

The deployment role does not use AdministratorAccess, AmazonS3FullAccess, or broad CloudFront permissions. At bucket level it can call s3:ListBucket and s3:GetBucketLocation. At object level it can call s3:PutObject and s3:DeleteObject. For CloudFront, it can call only cloudfront:CreateInvalidation against the required resources, represented with <BUCKET_NAME>, <DISTRIBUTION_ID>, and <AWS_ACCOUNT_ID> placeholders.

The workflow itself requests only the permissions it needs:

permissions:
  contents: read
  id-token: write

id-token: write allows the workflow to request an OIDC token. It does not itself grant AWS permissions. A normal push to main successfully completed the build, OIDC authentication, S3 upload, and CloudFront invalidation.

Troubleshooting the OIDC Trust Relationship

The first attempt failed with:

Could not assume role with OIDC:
Not authorized to perform sts:AssumeRoleWithWebIdentity

The original trust relationship expected a different GitHub subject format. The actual token used the repository- and account-specific subject represented in sanitized form above. The claims were inspected without printing the token itself.

The trust policy was not broadened with a permissive wildcard. It was updated to the exact expected audience and subject for the main branch. The rerun succeeded, followed by a successful normal production push.

Exact identity trust may require an intentional update if the repository identity or deployment model changes. The practical lesson is to inspect the actual claims safely before defining trust conditions, not to make authentication succeed with a broad wildcard.

Security Guardrails in CI

CSP Hash Validation

The production CSP depends on the generated theme initialization script. A legitimate code change could modify that script and make the approved CSP hash invalid.

After building the site, the workflow reads dist/index.html, identifies the executable inline script on that page, requires exactly one, calculates its SHA-256 hash, and compares it with the approved value. A mismatch fails the job before AWS authentication.

This CI check detects unnoticed drift in the shared theme initialization script represented on the generated home page. It does not independently inspect every generated page, verify the deployed CSP or browser enforcement, or prove the overall website is secure.

This focused excerpt uses a placeholder to keep the example readable and to discourage copying a hash that must be generated and validated against the actual built inline script:

- name: Verify CSP inline script hash
  run: |
    EXPECTED_HASH="<APPROVED_HASH>"
    ACTUAL_HASH="$(calculate_reviewed_inline_script_hash)"

    if [ "$ACTUAL_HASH" != "$EXPECTED_HASH" ]; then
      echo "::error::Inline script changed. Review the CSP before deploying."
      exit 1
    fi

The generated hash matched the approved value and the pipeline passed. A change to that home-page script now blocks deployment until the script is reviewed and the approved CSP value is updated.

Deployment Cache Strategy

Astro assets under _astro receive:

public,max-age=31536000,immutable

Other site content receives:

public,max-age=0,must-revalidate

Hashed assets can be cached aggressively because a content change produces a new filename. HTML and other content should revalidate so visitors receive current page references. A CloudFront invalidation runs after deployment.

Security Validation

Security control validation results
ControlTestObserved resultStatus
Private S3 originDirect S3 request403 ForbiddenPASS
CloudFront deliveryPublic websiteAvailable through CloudFrontPASS
HTTP to HTTPSHTTP requestRedirect to HTTPSPASS
TLSExternal TLS assessmentGrade APASS
DNSSECValidating DNS queryAD flagPASS
Security headersExternal header assessmentA+PASS
CSP compatibilityExpected browser functionality and consoleExpected functionality continued; no relevant CSP console errors observedPASS
www redirectwww requestCanonical apex redirectPASS
Query preservationRedirected URL with query stringQuery preservedPASS
Custom 404Nonexistent routeHTTP 404PASS
WAF visibilityProduction traffic observationRequests visiblePASS
CloudWatch 4xxReal threshold eventAlarm triggeredPASS
SNSReal alarm deliveryNotification receivedPASS
CloudTrailManagement-event loggingDelivery confirmedPASS
GitHub OIDCSTS role assumptionTemporary AWS authentication succeededPASS
Deployment permission sufficiencyNormal main deploymentScoped role completed required deployment actionsPASS
CSP CI guardBuild-time hash validationApproved hash matchedPASS
Security control validation results
ControlObserved result
Private S3 origin403 Forbidden Test: Direct S3 request. Status: PASS.
CloudFront deliveryAvailable through CloudFront Test: Public website. Status: PASS.
HTTP to HTTPSRedirect to HTTPS Test: HTTP request. Status: PASS.
TLSGrade A Test: External TLS assessment. Status: PASS.
DNSSECAD flag Test: Validating DNS query. Status: PASS.
Security headersA+ Test: External header assessment. Status: PASS.
CSP compatibilityExpected functionality continued; no relevant CSP console errors observed Test: Expected browser functionality and console. Status: PASS.
www redirectCanonical apex redirect Test: www request. Status: PASS.
Query preservationQuery preserved Test: Redirected URL with query string. Status: PASS.
Custom 404HTTP 404 Test: Nonexistent route. Status: PASS.
WAF visibilityRequests visible Test: Production traffic observation. Status: PASS.
CloudWatch 4xxAlarm triggered Test: Real threshold event. Status: PASS.
SNSNotification received Test: Real alarm delivery. Status: PASS.
CloudTrailDelivery confirmed Test: Management-event logging. Status: PASS.
GitHub OIDCTemporary AWS authentication succeeded Test: STS role assumption. Status: PASS.
Deployment permission sufficiencyScoped role completed required deployment actions Test: Normal main deployment. Status: PASS.
CSP CI guardApproved hash matched Test: Build-time hash validation. Status: PASS.

PASS does not mean “secure against everything.” It means the tested property behaved as expected during this validation.

For example, a 403 response from the tested direct S3 path proves that the tested origin path was not publicly accessible at that time. It does not guarantee that every possible future S3 misconfiguration can never occur.

What Failed and What I Learned

DNS Migration and Local Resolver Cache

After migration to Route 53, authoritative DNS was correct, but one local network continued returning stale information. Some clients worked while another local path did not.

Authoritative nameservers and external resolvers were checked before changing AWS. Changing the local network resolved the problem. The useful investigation order was registrar delegation → authoritative DNS → recursive resolver → local cache → application or browser. A failure on one client does not automatically mean authoritative DNS is wrong.

CSP: Functionality vs. Security

The initial policy used script-src 'self' 'unsafe-inline'. Instead of accepting that setting permanently, the build was inspected. One required executable inline script was identified, its purpose was understood, and it was authorized by SHA-256.

The lesson was to observe → identify → understand → authorize → retest.

A Real 4xx Alarm Did Not Automatically Mean an Attack

The alarm and SNS notification worked, and traffic had increased. However, available WAF telemetry did not provide enough evidence to call the event malicious.

Alert ≠ confirmed incident. Percentage-based alarms also become sensitive when a low-traffic site receives only a small number of requests.

CloudFront Custom Error Response Limitation

Normal responses receive both managed headers and function-added CSP and Permissions-Policy headers. The custom 404 path does not receive the function-added headers in exactly the same way.

The limitation was documented instead of adding Lambda@Edge only for this case. The decision considered risk + likelihood + impact + operational complexity + cost.

GitHub OIDC Trust Policy Mismatch

The detailed CI/CD section records the incident. The lasting lesson is to inspect actual identity claims before defining exact trust conditions, not to weaken trust with broad wildcards merely to make authentication succeed.

Trade-offs and Accepted Risks

Accepted security trade-offs and reasons
DecisionAccepted limitationReason
WAF monitor modeSuspicious requests are not automatically blockedEstablish a baseline before enforcement
HSTS without includeSubDomainsHSTS does not cover every subdomainPreserve an existing HTTP-dependent subdomain
style-src 'unsafe-inline'Inline CSS remains permittedSupport current legitimate style requirements
Custom 404Not all function-added headers are presentLambda@Edge complexity is not justified
CloudTrail management events onlyNo S3 data-event audit trailCurrent requirement focuses on control-plane changes
No additional CloudTrail KMS layerNo customer-managed key for these logsAdditional key management is not required for this scope
No GuardDuty, Security Hub, or AWS ConfigLess automated security visibilityNot justified by the current environment and operational scope
Percentage-based alarmsPossible noise at low trafficBaseline and future tuning are required
Decision: WAF monitor mode
Accepted limitationSuspicious requests are not automatically blocked
ReasonEstablish a baseline before enforcement
Decision: HSTS without includeSubDomains
Accepted limitationHSTS does not cover every subdomain
ReasonPreserve an existing HTTP-dependent subdomain
Decision: style-src 'unsafe-inline'
Accepted limitationInline CSS remains permitted
ReasonSupport current legitimate style requirements
Decision: Custom 404
Accepted limitationNot all function-added headers are present
ReasonLambda@Edge complexity is not justified
Decision: CloudTrail management events only
Accepted limitationNo S3 data-event audit trail
ReasonCurrent requirement focuses on control-plane changes
Decision: No additional CloudTrail KMS layer
Accepted limitationNo customer-managed key for these logs
ReasonAdditional key management is not required for this scope
Decision: No GuardDuty, Security Hub, or AWS Config
Accepted limitationLess automated security visibility
ReasonNot justified by the current environment and operational scope
Decision: Percentage-based alarms
Accepted limitationPossible noise at low traffic
ReasonBaseline and future tuning are required

An accepted risk is a documented decision, not an ignored risk.

Final Architecture and Lessons Learned

The final model has three connected paths:

  • Public: Internet → Route 53 and DNSSEC → CloudFront with TLS, WAF, headers, and CSP → OAC → private S3.
  • Deployment: GitHub → Actions → OIDC → STS → least-privilege IAM role → S3 and CloudFront invalidation.
  • Operations: CloudWatch → SNS, supported by CloudTrail and AWS Budgets.

Private by default. A public website does not require a public storage origin.

Identity is better than stored credentials. OIDC and STS remove the need for permanent AWS deployment keys in GitHub.

Configuration is not validation. S3 privacy was checked with a direct request, DNSSEC with a validating resolver, HTTPS with a redirect test, CSP through browser behavior, CloudWatch and SNS through a real notification, and OIDC through a real deployment.

Monitoring requires investigation. Observability provides evidence; analysis provides context.

Security belongs in the delivery process. The CSP guard moves one security assumption into CI.

More security services do not automatically mean better security. Each control adds cost and operational work as well as protection.

“What risk does this control address, and is that risk sufficient to justify its cost and operational complexity?”

Conclusion

The main technical takeaway is that controls become useful when their purpose, behavior, and limitations are understood and tested. For this site, secure delivery became part of the architecture and deployment process, with evidence and accepted trade-offs documented alongside the implementation.

Resources and Implementation

The public website repository contains the source and the deployment workflow at .github/workflows/deploy.yml.

The workflow demonstrates build → validation → CSP integrity check → GitHub OIDC authentication → S3 deployment → CloudFront invalidation.

The repository does not claim that the complete AWS environment is Infrastructure as Code. It contains the website source and deployment workflow, while some AWS infrastructure configuration was performed directly in AWS.