<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kehinde Abiuwa</title>
    <description>The latest articles on DEV Community by Kehinde Abiuwa (@kehindeabiuwadotcom).</description>
    <link>https://dev.to/kehindeabiuwadotcom</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3985980%2Fc8bc5603-c805-48f1-aca8-2547e95e233b.png</url>
      <title>DEV Community: Kehinde Abiuwa</title>
      <link>https://dev.to/kehindeabiuwadotcom</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kehindeabiuwadotcom"/>
    <language>en</language>
    <item>
      <title>Wiring Together a Three-Tier Serverless Web App on AWS (And the CORS Bug That Broke Everything)</title>
      <dc:creator>Kehinde Abiuwa</dc:creator>
      <pubDate>Wed, 17 Jun 2026 21:14:55 +0000</pubDate>
      <link>https://dev.to/kehindeabiuwadotcom/wiring-together-a-three-tier-serverless-web-app-on-aws-and-the-cors-bug-that-broke-everything-1l1j</link>
      <guid>https://dev.to/kehindeabiuwadotcom/wiring-together-a-three-tier-serverless-web-app-on-aws-and-the-cors-bug-that-broke-everything-1l1j</guid>
      <description>&lt;p&gt;After three parts of building individual pieces — a CloudFront frontend, a Lambda API, and a DynamoDB data layer — we now have three working components that have never actually spoken to each other.&lt;/p&gt;

&lt;p&gt;This is the part where you find out if your architecture actually holds together. And almost always, the answer is: "not yet, there is a CORS error."&lt;/p&gt;

&lt;p&gt;This is the final part of the series. We wire everything together, debug the inevitable integration failures, and end up with a working end-to-end serverless web application on AWS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkb98zjgq9zmubibf994h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkb98zjgq9zmubibf994h.png" alt="The completed three-tier serverless web app on AWS" width="800" height="458"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;All three tiers finally wired together into one working application.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Complete Architecture
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User's Browser
     │
     ▼
Amazon CloudFront  ◄────── S3 Bucket (private, OAC)
     │                     [index.html, style.css, script.js]
     │  GET /users?userId=1
     ▼
Amazon API Gateway (REST API, /prod stage)
     │  Lambda Proxy Integration
     ▼
AWS Lambda (RetrieveUserData)
     │  dynamodb:GetItem  [IAM inline policy, scoped to table ARN]
     ▼
Amazon DynamoDB (UserData table, On-Demand)
     │
     └── { userId: "1", name: "Test User", email: "test@example.com" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each tier is independently scalable, independently deployable, and locked down with least-privilege IAM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmtui9c0jte3z1re613l4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmtui9c0jte3z1re613l4.png" alt="Part 4 full three-tier architecture diagram" width="799" height="363"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The complete three-tier architecture — S3/CloudFront frontend, API Gateway + Lambda logic tier, and DynamoDB data tier.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Connect the Presentation Tier to the Logic Tier
&lt;/h2&gt;

&lt;p&gt;The frontend (&lt;code&gt;script.js&lt;/code&gt;) has a placeholder URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://YOUR-API-ID.execute-api.YOUR-REGION.amazonaws.com/prod/users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I replaced this with the real API Gateway Invoke URL, re-uploaded &lt;code&gt;script.js&lt;/code&gt; to S3, and opened the CloudFront URL in my browser.&lt;/p&gt;

&lt;p&gt;Immediately: a console error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Failed to fetch
TypeError: Failed to fetch
    at fetchUser (script.js:14)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a CORS error — a completely failed fetch. The URL was still the placeholder because &lt;strong&gt;CloudFront was serving the old cached version of &lt;code&gt;script.js&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  CloudFront Cache Invalidation
&lt;/h3&gt;

&lt;p&gt;When you update a file in S3, CloudFront continues serving the cached version from its edge locations until the TTL expires (default: 24 hours for the CachingOptimized policy). You need to manually invalidate the cache to force a refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudfront create-invalidation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--distribution-id&lt;/span&gt; YOUR-DISTRIBUTION-ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--paths&lt;/span&gt; &lt;span class="s2"&gt;"/script.js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or invalidate everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudfront create-invalidation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--distribution-id&lt;/span&gt; YOUR-DISTRIBUTION-ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--paths&lt;/span&gt; &lt;span class="s2"&gt;"/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the invalidation, I reloaded the page. New error.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The CORS Error
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Access to fetch at 'https://abc123.execute-api.eu-north-1.amazonaws.com/prod/users?userId=1'
from origin 'https://d1234abcd.cloudfront.net' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most common error in serverless web app development. Let me explain exactly what is happening.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4v7xy03hn47jwch2w2t5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4v7xy03hn47jwch2w2t5.png" alt="The CORS error shown in the browser console" width="799" height="438"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The browser console blocking the request — no Access-Control-Allow-Origin header present.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  What CORS Actually Is
&lt;/h3&gt;

&lt;p&gt;CORS (Cross-Origin Resource Sharing) is a browser security mechanism. A browser will refuse to complete a request from &lt;code&gt;origin-A.com&lt;/code&gt; to &lt;code&gt;origin-B.com&lt;/code&gt; unless &lt;code&gt;origin-B.com&lt;/code&gt; explicitly says "I allow requests from &lt;code&gt;origin-A.com&lt;/code&gt;." It does this by returning an &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;In our case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend origin:&lt;/strong&gt; &lt;code&gt;https://d1234abcd.cloudfront.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API origin:&lt;/strong&gt; &lt;code&gt;https://abc123.execute-api.eu-north-1.amazonaws.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are different origins (different hostnames). The browser blocks the API call until the API starts returning the right headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a browser-enforced mechanism.&lt;/strong&gt; CORS has no effect on server-to-server requests (curl, Postman, Lambda-to-Lambda). It only applies when a browser makes a cross-origin request. This is why your API worked fine in Postman but fails in the browser.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Two Places CORS Must Be Configured
&lt;/h3&gt;

&lt;p&gt;With Lambda Proxy Integration, CORS must be configured in &lt;strong&gt;two places&lt;/strong&gt;. Most tutorials only mention one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Place 1: API Gateway&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In API Gateway, you enable CORS on the resource. This handles the preflight &lt;code&gt;OPTIONS&lt;/code&gt; request that the browser sends before the actual &lt;code&gt;GET&lt;/code&gt;. Configure it with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Access-Control-Allow-Origin: https://d1234abcd.cloudfront.net&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Access-Control-Allow-Methods: GET,OPTIONS&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Access-Control-Allow-Headers: Content-Type,Authorization&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Place 2: Lambda function&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With proxy integration, API Gateway passes the raw Lambda response directly to the browser. It does not add any headers that are not in the Lambda response itself. So if your Lambda does not include CORS headers in its response, the browser gets a response without &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; and blocks it.&lt;/p&gt;

&lt;p&gt;Your Lambda must return CORS headers on every response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CORS_HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_ORIGIN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET,OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type,Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CORS_HEADERS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler must also respond to &lt;code&gt;OPTIONS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is documented in the AWS docs but buried. The conceptual model to remember is: &lt;strong&gt;API Gateway handles the preflight. Lambda handles the actual response headers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4m86apx4sdx8bjbacjrr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4m86apx4sdx8bjbacjrr.png" alt="Enabling CORS on the API Gateway resource" width="800" height="454"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Enabling CORS on the API Gateway resource — one of the two places it must be configured.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  How Browser CORS Preflight Works
&lt;/h3&gt;

&lt;p&gt;For any cross-origin request with a non-simple method or custom headers, the browser first sends an &lt;code&gt;OPTIONS&lt;/code&gt; request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;OPTIONS&lt;/span&gt; &lt;span class="nn"&gt;/users&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;abc123.execute-api.eu-north-1.amazonaws.com&lt;/span&gt;
&lt;span class="na"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://d1234abcd.cloudfront.net&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Request-Method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Request-Headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;content-type&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your API must respond:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://d1234abcd.cloudfront.net&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET,OPTIONS&lt;/span&gt;
&lt;span class="na"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Content-Type,Authorization&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only then does the browser proceed with the actual &lt;code&gt;GET&lt;/code&gt; request. And that &lt;code&gt;GET&lt;/code&gt; response must also include &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; or the browser blocks it even after the preflight succeeded.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Verifying End-to-End
&lt;/h2&gt;

&lt;p&gt;After fixing both CORS locations, I:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Redeployed the API Gateway stage (changes to the resource CORS configuration require a new deployment)&lt;/li&gt;
&lt;li&gt;Re-uploaded &lt;code&gt;script.js&lt;/code&gt; to S3&lt;/li&gt;
&lt;li&gt;Ran another CloudFront invalidation&lt;/li&gt;
&lt;li&gt;Reloaded the CloudFront URL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then typed &lt;code&gt;1&lt;/code&gt; in the User ID field and clicked the button.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Test User"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test@example.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Network tab showed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;OPTIONS /users&lt;/code&gt; → 200 (preflight)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /users?userId=1&lt;/code&gt; → 200 (actual request)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full three-tier stack was working end-to-end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqgfc2u5cwrxpwvkpf3v9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqgfc2u5cwrxpwvkpf3v9.png" alt="The working app with OPTIONS and GET both returning 200" width="800" height="469"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The fixed solution — OPTIONS and GET both return 200 and the user data renders.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Deployment Checklist
&lt;/h2&gt;

&lt;p&gt;Here is the checklist I now follow whenever I update any component of this stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Actions required&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Update &lt;code&gt;script.js&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Re-upload to S3, create CloudFront invalidation for &lt;code&gt;/script.js&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update &lt;code&gt;index.html&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Re-upload to S3, create CloudFront invalidation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update Lambda code&lt;/td&gt;
&lt;td&gt;Deploy new function version (console or CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update Lambda env vars&lt;/td&gt;
&lt;td&gt;No redeployment needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update API Gateway routes&lt;/td&gt;
&lt;td&gt;Must create a new Deployment and associate with stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update IAM policies&lt;/td&gt;
&lt;td&gt;Takes effect immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CloudFront invalidation step is the one people forget most often. After wondering why your changes are not showing up, run &lt;code&gt;aws cloudfront create-invalidation --paths "/*"&lt;/code&gt; — that will usually be the fix.&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture Decisions: What I Would Do Differently in Production
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Use a Custom Domain
&lt;/h3&gt;

&lt;p&gt;The CloudFront URL (&lt;code&gt;d1234abcd.cloudfront.net&lt;/code&gt;) is not something you want in a production app. You would:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Register a domain in Route 53 (or bring your own)&lt;/li&gt;
&lt;li&gt;Request an ACM certificate (free, in &lt;code&gt;us-east-1&lt;/code&gt; for CloudFront)&lt;/li&gt;
&lt;li&gt;Add a CNAME alias to your CloudFront distribution&lt;/li&gt;
&lt;li&gt;Update the &lt;code&gt;ALLOWED_ORIGIN&lt;/code&gt; env var on Lambda to your real domain&lt;/li&gt;
&lt;li&gt;Never use &lt;code&gt;"*"&lt;/code&gt; for &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; in production&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  2. Lock Down CORS
&lt;/h3&gt;

&lt;p&gt;In the code, &lt;code&gt;ALLOWED_ORIGIN&lt;/code&gt; defaults to &lt;code&gt;"*"&lt;/code&gt;. This is fine for development. In production, this should be your specific CloudFront domain. This ensures that other sites cannot trigger your API with a user's browser credentials.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Add Structured Logging
&lt;/h3&gt;

&lt;p&gt;The current CloudWatch logs are unstructured strings. For a production API, I would use structured JSON logging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;durationMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes CloudWatch Logs Insights queries much more powerful.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Add a CloudWatch Alarm on Lambda Errors
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudwatch put-metric-alarm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alarm-name&lt;/span&gt; &lt;span class="s2"&gt;"LambdaErrorRate"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metric-name&lt;/span&gt; Errors &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; AWS/Lambda &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dimensions&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FunctionName,Value&lt;span class="o"&gt;=&lt;/span&gt;three-tier-app-retrieve-user-data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threshold&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--comparison-operator&lt;/span&gt; GreaterThanOrEqualToThreshold &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--evaluation-periods&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--period&lt;/span&gt; 60 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--statistic&lt;/span&gt; Sum &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alarm-actions&lt;/span&gt; arn:aws:sns:region:account:my-alert-topic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Deploy with Infrastructure as Code
&lt;/h3&gt;

&lt;p&gt;The CloudFormation template in this repository (&lt;code&gt;infrastructure/cloudformation/three-tier-stack.yaml&lt;/code&gt;) provisions the entire stack — S3, CloudFront, DynamoDB, Lambda, API Gateway, IAM — in a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudformation deploy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template-file&lt;/span&gt; infrastructure/cloudformation/three-tier-stack.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stack-name&lt;/span&gt; three-tier-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--capabilities&lt;/span&gt; CAPABILITY_NAMED_IAM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--parameter-overrides&lt;/span&gt; &lt;span class="nv"&gt;ProjectName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;three-tier-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tearing it all down is equally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudformation delete-stack &lt;span class="nt"&gt;--stack-name&lt;/span&gt; three-tier-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of the biggest operational advantages of IaC: reproducibility. You can spin up an identical copy of this environment in any region in minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned From This Series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CORS is a browser mechanism, not an API mechanism.&lt;/strong&gt; It cannot be tested with curl alone. Always test from a browser when cross-origin requests are involved.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CloudFront caching is aggressive by default.&lt;/strong&gt; Develop a habit of running cache invalidations after every frontend update, or use asset hashing (&lt;code&gt;main.abc123.js&lt;/code&gt;) so updated files have new names and old ones can be cached forever.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Least-privilege IAM is practical, not academic.&lt;/strong&gt; Scoping a Lambda role to &lt;code&gt;dynamodb:GetItem&lt;/code&gt; on a specific table ARN takes three minutes and meaningfully reduces your blast radius.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OAC is the correct way to serve S3 content via CloudFront.&lt;/strong&gt; There is no reason to make an S3 bucket public if CloudFront is in front of it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CloudFormation templates are their own documentation.&lt;/strong&gt; The template in this repository tells you exactly what was provisioned, how components relate to each other, and what every configuration choice was.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;Everything covered in this series is in the GitHub repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws-three-tier-serverless/
├── frontend/          # index.html, style.css, script.js
├── backend/lambda/    # Lambda function code (Node.js ESM)
├── infrastructure/
│   ├── cloudformation/   # Full CloudFormation stack
│   ├── iam/              # IAM policy documents
│   └── s3-bucket-policy.json
└── docs/articles/     # This article series
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/kehindeabiuwa-dotcom/aws-three-tier-serverless" rel="noopener noreferrer"&gt;aws-three-tier-serverless on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Kehinde Abiuwa — AWS Certified Solutions Architect (Professional) | Microsoft AZ-305&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Open to Solutions Architect roles (remote/hybrid).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>cors</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Least-Privilege IAM for Lambda: Why I Replaced the AWS Managed Policy With My Own</title>
      <dc:creator>Kehinde Abiuwa</dc:creator>
      <pubDate>Wed, 17 Jun 2026 21:14:43 +0000</pubDate>
      <link>https://dev.to/kehindeabiuwadotcom/least-privilege-iam-for-lambda-why-i-replaced-the-aws-managed-policy-with-my-own-9nh</link>
      <guid>https://dev.to/kehindeabiuwadotcom/least-privilege-iam-for-lambda-why-i-replaced-the-aws-managed-policy-with-my-own-9nh</guid>
      <description>&lt;p&gt;There is a moment in almost every AWS tutorial where the author says "attach &lt;code&gt;AmazonDynamoDBFullAccess&lt;/code&gt; to your Lambda role" and moves on.&lt;/p&gt;

&lt;p&gt;I understand why. It is fast. It makes the tutorial work. And for a throwaway demo it probably does not matter.&lt;/p&gt;

&lt;p&gt;But if you are building anything real, you have just handed your serverless function the keys to every DynamoDB table in your account — including the ability to delete them.&lt;/p&gt;

&lt;p&gt;In this article, I want to do something slightly different. I will show you the &lt;strong&gt;journey&lt;/strong&gt; I took: start with the broad managed policy that AWS recommends, understand why it is wrong for production, and then tighten it down to the minimum permissions the function actually needs. This is the most important security lesson I learned while building this three-tier app.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where We Are in the Series
&lt;/h2&gt;

&lt;p&gt;This is Part 3 of a four-part series building a serverless web app on AWS:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;S3 + CloudFront (frontend delivery)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Lambda + API Gateway (the REST API)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DynamoDB + Least-Privilege IAM (data tier + security)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Wiring everything together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In Part 2 we built a Lambda function that calls DynamoDB. We intentionally left IAM incomplete — the function would throw &lt;code&gt;AccessDenied&lt;/code&gt; if you called it. This part fixes that properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Set Up the DynamoDB Table
&lt;/h2&gt;

&lt;p&gt;The data tier is a single DynamoDB table named &lt;code&gt;UserData&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Table design:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;userId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Partition key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the entire schema — because DynamoDB is schemaless. Each item can have whatever attributes you want. Only the partition key (&lt;code&gt;userId&lt;/code&gt;) must be present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;userId&lt;/code&gt; as the partition key?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB stores and retrieves items by their partition key. With &lt;code&gt;userId&lt;/code&gt; as the key, looking up a specific user is an O(1) &lt;code&gt;GetItem&lt;/code&gt; operation — constant time regardless of how many users are in the table. If we modelled this as a relational table and queried by a non-indexed column, we would be doing a full table scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capacity mode:&lt;/strong&gt; I used On-Demand (PAY_PER_REQUEST). No need to predict throughput. DynamoDB scales automatically and you pay only for what you use. For a production app with predictable traffic, Provisioned capacity with Auto Scaling is more cost-efficient.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl507h3xc6kw55h64np1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl507h3xc6kw55h64np1w.png" alt="The UserData table in the DynamoDB console" width="800" height="378"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The UserData table created in DynamoDB with userId as the partition key.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seed data:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Test User"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test@example.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: The First IAM Mistake (Intentional)
&lt;/h2&gt;

&lt;p&gt;With the table created, I attached a permission policy to the Lambda execution role.&lt;/p&gt;

&lt;p&gt;AWS shows you six DynamoDB-related managed policies. Two of them look like they are for Lambda + DynamoDB:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AWSLambdaDynamoDBExecutionRole&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWSLambdaInvocation-DynamoDB&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Do not use either of these.&lt;/strong&gt; They sound right but are designed for a completely different pattern — DynamoDB Streams, where DynamoDB pushes events to Lambda. They do not grant &lt;code&gt;dynamodb:GetItem&lt;/code&gt; on a table. Attaching them does nothing for our use case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyd771lthphl4y1b4snpt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyd771lthphl4y1b4snpt.png" alt="The DynamoDB managed policies shown when attaching permissions" width="800" height="432"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The DynamoDB-related managed policies AWS offers when attaching permissions to the role.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I attached &lt;code&gt;AmazonDynamoDBReadOnlyAccess&lt;/code&gt; instead. And it worked. The Lambda could now retrieve user records.&lt;/p&gt;

&lt;p&gt;But I was not done.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy145n1udolijjfinnwq4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy145n1udolijjfinnwq4.png" alt="Attaching the AmazonDynamoDBReadOnlyAccess managed policy" width="799" height="528"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The intentional first mistake — attaching the broad AmazonDynamoDBReadOnlyAccess managed policy.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Understanding What &lt;code&gt;AmazonDynamoDBReadOnlyAccess&lt;/code&gt; Actually Allows
&lt;/h2&gt;

&lt;p&gt;Let me show you what this managed policy actually grants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:BatchGetItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ConditionCheckItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeExport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeGlobalTable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeGlobalTableSettings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeImport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeKinesisStreamingDestination"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeTable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeTableReplicaAutoScaling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeTimeToLive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetRecords"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetShardIterator"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListContributorInsights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListExports"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListGlobalTables"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListImports"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListStreams"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListTables"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:ListTagsOfResource"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:Query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:Scan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetResourcePolicy"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;"Resource": "*"&lt;/code&gt; means every DynamoDB table in the account. And the action list includes &lt;code&gt;Scan&lt;/code&gt; — which can read every item in every table. It also includes &lt;code&gt;ListTables&lt;/code&gt;, &lt;code&gt;DescribeTable&lt;/code&gt;, and more.&lt;/p&gt;

&lt;p&gt;My Lambda function does exactly one thing: call &lt;code&gt;GetItem&lt;/code&gt; on the &lt;code&gt;UserData&lt;/code&gt; table. The gap between what the function needs and what this policy grants is enormous.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Writing the Least-Privilege Inline Policy
&lt;/h2&gt;

&lt;p&gt;I replaced the managed policy with a custom inline policy scoped to exactly what the function needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DynamoDBGetItemOnly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetItem"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:dynamodb:eu-north-1:123456789012:table/UserData"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CloudWatchLogs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogGroup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogStream"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:PutLogEvents"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:logs:eu-north-1:123456789012:log-group:/aws/lambda/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two statements. That is all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fphcto5kcoi522lgy5vyv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fphcto5kcoi522lgy5vyv.png" alt="The least-privilege inline policy in the IAM console" width="800" height="431"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The custom inline policy scoped to dynamodb:GetItem on the UserData table only.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;dynamodb:GetItem&lt;/code&gt; — on the specific &lt;code&gt;UserData&lt;/code&gt; table ARN only. No other tables. No other actions.&lt;/li&gt;
&lt;li&gt;CloudWatch Logs — so the function can write its execution logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why an inline policy instead of a new managed policy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inline policies are attached directly to the role and cannot be accidentally shared with another role. For narrow, function-specific permissions like this, inline is the right choice. Managed policies are better suited for shared, reusable permission sets (like "all Lambda functions need CloudWatch Logs access").&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 5: Validating the Change
&lt;/h2&gt;

&lt;p&gt;After attaching the inline policy, I re-ran the Lambda test event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test@example.com&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Test User&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;userId&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I removed the managed policy. Tested again. Same result.&lt;/p&gt;

&lt;p&gt;Then I deliberately tested an error case — a &lt;code&gt;userId&lt;/code&gt; that does not exist in the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"999"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;No user found with userId: 999&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I checked CloudWatch Logs to confirm there were no &lt;code&gt;AccessDenied&lt;/code&gt; errors anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmlufhk4zjnckckwi7p5p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmlufhk4zjnckckwi7p5p.png" alt="The Lambda test event returning a 200 with the user item" width="800" height="452"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Lambda test returning 200 with the user item — confirming the scoped policy works.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters Beyond Theory
&lt;/h2&gt;

&lt;p&gt;The principle of least privilege is not just a compliance checkbox. It has real security implications:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius reduction:&lt;/strong&gt; If this Lambda function is ever exploited — through a code injection vulnerability, a dependency supply chain attack, or a misconfiguration — the attacker can only call &lt;code&gt;GetItem&lt;/code&gt; on &lt;code&gt;UserData&lt;/code&gt;. They cannot read your other tables, scan your entire database, or list your table names to understand your data model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditability:&lt;/strong&gt; When someone reads the IAM policy attached to this function, they immediately understand exactly what it does and what it can access. A &lt;code&gt;"Resource": "*"&lt;/code&gt; with 25 actions tells you nothing about intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easier incident response:&lt;/strong&gt; If you get a security alert about unusual DynamoDB activity and you know the only thing that can touch &lt;code&gt;UserData&lt;/code&gt; with &lt;code&gt;GetItem&lt;/code&gt; is the &lt;code&gt;RetrieveUserData&lt;/code&gt; Lambda, your investigation is much more focused.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common IAM Mistakes to Avoid
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Why It Matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;"Resource": "*"&lt;/code&gt; on data services&lt;/td&gt;
&lt;td&gt;Grants access to every table/bucket/secret in the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using &lt;code&gt;FullAccess&lt;/code&gt; managed policies for read-only functions&lt;/td&gt;
&lt;td&gt;Allows writes and deletes your function will never need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not scoping CloudWatch Logs to the function's log group&lt;/td&gt;
&lt;td&gt;Allows the function to write to any log group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attaching policies to users instead of roles&lt;/td&gt;
&lt;td&gt;Violates the principle of using roles for service-to-service auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not removing unused permissions when code changes&lt;/td&gt;
&lt;td&gt;Accumulated permissions that were once needed but no longer are&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The DynamoDB Data Model — Key Concepts
&lt;/h2&gt;

&lt;p&gt;Since we are in the data tier, it is worth briefly covering the DynamoDB concepts that matter here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition key (Hash key):&lt;/strong&gt; Determines which partition of DynamoDB's distributed storage your item lives on. Choose a partition key with high cardinality (many distinct values) to distribute load evenly. &lt;code&gt;userId&lt;/code&gt; works well — each user has a unique ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;GetItem&lt;/code&gt; vs &lt;code&gt;Query&lt;/code&gt; vs &lt;code&gt;Scan&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GetItem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retrieve exactly one item by its primary key&lt;/td&gt;
&lt;td&gt;0.5 RCU per read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retrieve multiple items by partition key + sort key condition&lt;/td&gt;
&lt;td&gt;Scales with result set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read every item in the table&lt;/td&gt;
&lt;td&gt;Expensive — avoid in production&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our function uses &lt;code&gt;GetItem&lt;/code&gt; with &lt;code&gt;userId&lt;/code&gt; as the key. This is the most efficient possible read operation for this use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-demand vs Provisioned capacity:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On-demand billing means DynamoDB handles scaling automatically. You pay per request. Good for unpredictable workloads. Provisioned capacity lets you set read/write capacity units and is cheaper at predictable, high volume. You can switch between modes once per 24 hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;In the final part, we wire all three tiers together — connecting the CloudFront frontend to the API Gateway backend, solving the CORS issues that arise when your frontend and backend live on different domains, and invalidating the CloudFront cache after updating the frontend files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 4 — Building the Complete Three-Tier Web App — coming next in this series.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Diagram
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwv9u05brdubrfgk9uzk5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwv9u05brdubrfgk9uzk5.png" alt="Part 3 architecture — Lambda to DynamoDB with the IAM policy as a boundary" width="800" height="881"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code for this series: &lt;a href="https://github.com/kehindeabiuwa-dotcom/aws-three-tier-serverless" rel="noopener noreferrer"&gt;aws-three-tier-serverless on GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Kehinde Abiuwa — AWS Certified Solutions Architect (Professional) | Microsoft AZ-305&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>iam</category>
      <category>dynamodb</category>
      <category>security</category>
    </item>
    <item>
      <title>Building a Serverless REST API with AWS Lambda and API Gateway</title>
      <dc:creator>Kehinde Abiuwa</dc:creator>
      <pubDate>Wed, 17 Jun 2026 21:13:59 +0000</pubDate>
      <link>https://dev.to/kehindeabiuwadotcom/building-a-serverless-rest-api-with-aws-lambda-and-api-gateway-8df</link>
      <guid>https://dev.to/kehindeabiuwadotcom/building-a-serverless-rest-api-with-aws-lambda-and-api-gateway-8df</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Part 2 of 4&lt;/strong&gt; — &lt;a href="https://dev.to/kehindeabiuwadotcom/how-i-delivered-a-static-website-globally-with-amazon-s3-and-cloudfront-and-the-security-mistake-i-2n38"&gt;Part 1: Static Hosting with S3 and CloudFront&lt;/a&gt; | Part 2: Lambda + API Gateway (you are here) | &lt;a href="https://dev.to/kehindeabiuwadotcom/least-privilege-iam-for-lambda-why-i-replaced-the-aws-managed-policy-with-my-own-aal-temp-slug-8511285?preview=d896d8d39d78f3f3ad339d3fc708e55f241ccc23f72a7bfda1d7efddc81c648a5cbe0ddc724fcba52442837d58265673e7f2792a4489e5727f940dbb"&gt;Part 3: DynamoDB and IAM&lt;/a&gt; | &lt;a href="https://dev.to/kehindeabiuwadotcom/wiring-together-a-three-tier-serverless-web-app-on-aws-and-the-cors-bug-that-broke-everything-3o10-temp-slug-6204859?preview=61a16fbeac03dd18681563576e076ca2a289e63c8451bf3789c3f307a3a00bd0e04bf6321c276b414ec50eed9ae600a1b1962a98c0d76146c05afb30"&gt;Part 4: Full App Integration and CORS&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The first time I hit a "Missing Authentication Token" error from API Gateway, I assumed the problem was with authentication. So I spent twenty minutes checking IAM permissions, looking for a missing auth header, and rereading the API Gateway docs.&lt;/p&gt;

&lt;p&gt;The problem had nothing to do with authentication. I had simply called the wrong URL — the API root instead of the resource path. The error message is genuinely misleading.&lt;/p&gt;

&lt;p&gt;This is Part 2 of a four-part series where I build a serverless web app on AWS from scratch. In Part 1, I set up an S3 bucket and CloudFront distribution for the frontend. In this part, we are building the &lt;strong&gt;logic tier&lt;/strong&gt;: a Lambda function wired to API Gateway that will form the backbone of the backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;A REST API with a single endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /users?userId={id}
→ Returns user data as JSON from DynamoDB (coming in Part 3)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The complete flow:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser / curl
    │
    ▼
API Gateway (REST API)
    │  Routes GET /users → Lambda proxy integration
    ▼
AWS Lambda (RetrieveUserData function)
    │  Queries DynamoDB for the requested userId
    ▼
Amazon DynamoDB
    │
    └── Returns JSON item → Lambda → API Gateway → Browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 1: The Lambda Function
&lt;/h2&gt;
&lt;h3&gt;
  
  
  What Lambda Is — and Why It Makes Sense Here
&lt;/h3&gt;

&lt;p&gt;Lambda is compute-on-demand. You give AWS your code, and AWS runs it only when something triggers it. You pay per 100ms of execution, not for idle time.&lt;/p&gt;

&lt;p&gt;For a backend API serving variable load, this is compelling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No servers to patch or scale&lt;/li&gt;
&lt;li&gt;Cost is directly proportional to usage (free tier covers 1M requests/month)&lt;/li&gt;
&lt;li&gt;The execution role (IAM) controls exactly what the function can access — nothing more&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The Code
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backend/lambda/index.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-dynamodb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBDocumentClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GetCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/lib-dynamodb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_REGION&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;docClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBDocumentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CORS_HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_ORIGIN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET,OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type,Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CORS_HEADERS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryStringParameters&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing required query parameter: userId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;docClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`No user found with userId: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DynamoDB error:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Internal server error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Three things in this code that are worth understanding properly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;DynamoDBDocumentClient&lt;/code&gt; instead of &lt;code&gt;DynamoDBClient&lt;/code&gt; directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The raw &lt;code&gt;DynamoDBClient&lt;/code&gt; uses DynamoDB's type-annotated format: &lt;code&gt;{ userId: { S: "1" } }&lt;/code&gt;. The &lt;code&gt;DynamoDBDocumentClient&lt;/code&gt; is a higher-level abstraction that marshals and unmarshals these types automatically, so you work with plain JavaScript objects. Always use &lt;code&gt;DynamoDBDocumentClient&lt;/code&gt; unless you have a specific reason not to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why handle &lt;code&gt;OPTIONS&lt;/code&gt; explicitly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Modern browsers send a CORS preflight &lt;code&gt;OPTIONS&lt;/code&gt; request before any cross-origin &lt;code&gt;GET&lt;/code&gt;. If Lambda does not return a &lt;code&gt;200&lt;/code&gt; with the correct headers on &lt;code&gt;OPTIONS&lt;/code&gt;, the actual &lt;code&gt;GET&lt;/code&gt; never fires. This is the CORS issue that bites nearly everyone building a serverless API for the first time — and we will cover it in depth in Part 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;process.env.TABLE_NAME&lt;/code&gt; instead of a hardcoded table name?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Environment variables decouple your code from your infrastructure. When you deploy the same Lambda to a staging environment with a different table, you change the environment variable — not the code. This is a habit worth building from day one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdryxshv3trminbm1pvsg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdryxshv3trminbm1pvsg.png" alt="The RetrieveUserData Lambda function deployed in the AWS console" width="799" height="410"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The RetrieveUserData Lambda function in the AWS console, showing runtime (Node.js 22), handler, and environment variables.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 2: API Gateway
&lt;/h2&gt;
&lt;h3&gt;
  
  
  REST API vs HTTP API
&lt;/h3&gt;

&lt;p&gt;API Gateway offers two main flavours. HTTP API is newer, cheaper (~$1/million requests vs ~$3.50), and simpler to configure CORS on. REST API has more options: usage plans, request/response transformations, response caching, and WAF integration.&lt;/p&gt;

&lt;p&gt;I am using a REST API here because it maps more directly to what most production APIs require, and the exam (Solutions Architect Professional) tests REST API configuration in detail. For a greenfield project today, I would lean towards HTTP API unless I specifically needed those REST API features.&lt;/p&gt;
&lt;h3&gt;
  
  
  Resources and Methods
&lt;/h3&gt;

&lt;p&gt;An API is a tree of resources (URL paths) with HTTP methods attached:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/                 ← root resource
└── /users        ← our resource
    ├── GET       ← our method (→ Lambda)
    └── OPTIONS   ← CORS preflight (→ mock integration)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk7hd2h87lotq7hd64pl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk7hd2h87lotq7hd64pl.png" alt="The UserRequestAPI REST API in the API Gateway console" width="800" height="465"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The UserRequestAPI REST API created in the API Gateway console.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7po8a0mlwti0s2iquzi5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7po8a0mlwti0s2iquzi5.png" alt="The /users resource with GET and OPTIONS methods configured" width="800" height="376"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The /users resource with its GET method wired to Lambda and OPTIONS configured for CORS preflight.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Lambda Proxy Integration
&lt;/h3&gt;

&lt;p&gt;When you select &lt;strong&gt;Lambda Proxy Integration&lt;/strong&gt;, API Gateway passes the entire HTTP request as a JSON event object to your Lambda and expects Lambda to return a properly shaped response (&lt;code&gt;statusCode&lt;/code&gt;, &lt;code&gt;headers&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;). This is simpler and more flexible than mapping templates.&lt;/p&gt;

&lt;p&gt;The event object Lambda receives looks like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"httpMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"queryStringParameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Our handler reads &lt;code&gt;event.httpMethod&lt;/code&gt; and &lt;code&gt;event.queryStringParameters&lt;/code&gt; directly — that is the Lambda Proxy Integration contract in action.&lt;/p&gt;
&lt;h3&gt;
  
  
  Stages and Deployment
&lt;/h3&gt;

&lt;p&gt;Every time you change your API configuration in API Gateway, those changes are not live until you &lt;em&gt;deploy&lt;/em&gt; them to a &lt;em&gt;stage&lt;/em&gt;. A stage is a named snapshot of your API with its own invoke URL:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://abc123.execute-api.eu-north-1.amazonaws.com/prod
https://abc123.execute-api.eu-north-1.amazonaws.com/dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each stage can have different throttling limits, logging levels, and caching settings. I deployed to a stage named &lt;code&gt;prod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsksstnxzo72laznifrp3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsksstnxzo72laznifrp3.png" alt="Deploying the API to the prod stage and the resulting invoke URL" width="800" height="538"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Deploying the API to the prod stage and the resulting invoke URL.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is why "Missing Authentication Token" happens.&lt;/strong&gt; If you call the root of the stage URL (&lt;code&gt;/prod&lt;/code&gt;) instead of the resource path (&lt;code&gt;/prod/users&lt;/code&gt;), API Gateway does not recognise the route and returns that error. Always include the full resource path.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Part 3: Testing the API
&lt;/h2&gt;

&lt;p&gt;Before connecting the API to the frontend, I tested it directly with &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://abc123.execute-api.eu-north-1.amazonaws.com/prod/users?userId=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;At this stage, DynamoDB is not set up yet — that is Part 3. But if the API Gateway → Lambda wiring is correct, you will get a JSON error response, not a 403 or an AWS XML error. A JSON response tells you the plumbing is connected and Lambda is running.&lt;/p&gt;

&lt;p&gt;I also tested the Lambda function in isolation using the console test editor. Create a test event:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"httpMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"queryStringParameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This lets you confirm the function logic is working before adding the API Gateway layer to the debugging surface. Test the layers separately first.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 4: API Documentation
&lt;/h2&gt;

&lt;p&gt;Once the API is deployed, I exported an OpenAPI (Swagger) specification directly from API Gateway. This gives you a machine-readable description of your entire API:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"swagger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UserRequestAPI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-01-01T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123.execute-api.eu-north-1.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"basePath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/users"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"get"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"produces"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"responses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"200"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User data returned successfully"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"404"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User not found"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This matters for two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Any developer consuming your API can understand the contract without asking you&lt;/li&gt;
&lt;li&gt;You can import the spec into Postman, Insomnia, or Swagger UI for interactive testing and sharing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnhdwrvzj645t8gf50d91.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnhdwrvzj645t8gf50d91.png" alt="The OpenAPI Swagger specification exported from API Gateway" width="800" height="515"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The OpenAPI (Swagger) spec exported directly from the API Gateway console.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture Decisions and Trade-offs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why not use an HTTP API instead of a REST API?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;HTTP API is cheaper and has built-in CORS configuration (no manual &lt;code&gt;OPTIONS&lt;/code&gt; method needed). For a new project today, I would use HTTP API. The REST API here was chosen to cover the full API Gateway feature surface and because the Solutions Architect Professional exam tests REST API configuration specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not deploy the Lambda inside a VPC?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB has a public endpoint — Lambda running outside a VPC can reach it without a NAT Gateway. Putting Lambda inside a VPC adds NAT Gateway cost (~$0.045/hour always-on) and configuration complexity without meaningful security benefit for this architecture. If DynamoDB were replaced with an RDS instance in a private subnet, VPC deployment would be required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a single Lambda function for the entire API?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This function does one thing: retrieve a user by ID. Single-responsibility functions are easier to test, deploy independently, and grant precise IAM permissions to. As the app grows, write operations would get separate functions rather than expanding this one. One function per operation is the pattern.&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture Diagram
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv7iwvrv3n0ut1zrk9hlz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv7iwvrv3n0ut1zrk9hlz.png" alt="Part 2 architecture — Browser to API Gateway to Lambda to DynamoDB" width="799" height="348"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Logic tier: API Gateway routes requests to Lambda via proxy integration. Lambda will query DynamoDB in Part 3.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;In Part 3, we wire up the data tier: creating a DynamoDB table, seeding it with test records, and tightening the IAM policy from a broad managed policy down to a precise inline policy scoped to a single table and a single action — &lt;code&gt;dynamodb:GetItem&lt;/code&gt; on &lt;code&gt;UserData&lt;/code&gt; only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 3 → Fetch Data with AWS Lambda and DynamoDB →&lt;/strong&gt;&lt;/p&gt;




&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/kehindeabiuwa-dotcom" rel="noopener noreferrer"&gt;
        kehindeabiuwa-dotcom
      &lt;/a&gt; / &lt;a href="https://github.com/kehindeabiuwa-dotcom/aws-three-tier-serverless" rel="noopener noreferrer"&gt;
        aws-three-tier-serverless
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Production-grade three-tier serverless web app on AWS — private S3 + CloudFront (OAC), Lambda + API Gateway REST API, and DynamoDB with least-privilege IAM. Deploys in one CloudFormation command.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;AWS Three-Tier Serverless Web App&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A production-grade, serverless three-tier web application built entirely on AWS managed services — no EC2, no containers, no servers to manage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Architecture:&lt;/strong&gt; S3 + CloudFront → API Gateway → Lambda → DynamoDB&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/23fd68a8a424d4f31889cadcdbeacf043af41ac1601c87f2d7de9d1e64a45c1e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4157532d5365727665726c6573732d464639393030"&gt;&lt;img src="https://camo.githubusercontent.com/23fd68a8a424d4f31889cadcdbeacf043af41ac1601c87f2d7de9d1e64a45c1e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4157532d5365727665726c6573732d464639393030" alt="AWS"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/fc05531334b0c0450f70ab15be0f1a5cd3b15e3344b73be80e493ea5abfc3ffe/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4961432d436c6f7564466f726d6174696f6e2d374234324243"&gt;&lt;img src="https://camo.githubusercontent.com/fc05531334b0c0450f70ab15be0f1a5cd3b15e3344b73be80e493ea5abfc3ffe/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4961432d436c6f7564466f726d6174696f6e2d374234324243" alt="IaC"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/95f425150cec591d298909f9f24dbfe75b631a7f22be63b5fbebd1045e3851cf/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c616d6264612d4e6f64652e6a7325323032322d333339393333"&gt;&lt;img src="https://camo.githubusercontent.com/95f425150cec591d298909f9f24dbfe75b631a7f22be63b5fbebd1045e3851cf/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c616d6264612d4e6f64652e6a7325323032322d333339393333" alt="Runtime"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/a5d1b33f659fda3deac324fd2139f64429557c322413c9e9ffa0096cca3dcbf8/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73746172732f6b6568696e64656162697577612d646f74636f6d2f6177732d74687265652d746965722d7365727665726c6573733f7374796c653d736f6369616c"&gt;&lt;img src="https://camo.githubusercontent.com/a5d1b33f659fda3deac324fd2139f64429557c322413c9e9ffa0096cca3dcbf8/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73746172732f6b6568696e64656162697577612d646f74636f6d2f6177732d74687265652d746965722d7365727665726c6573733f7374796c653d736f6369616c" alt="GitHub stars"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/0a5dfffb381671ce814745108c72a532c2896b8bb03017b7c4e1efb926b55d61/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f666f726b732f6b6568696e64656162697577612d646f74636f6d2f6177732d74687265652d746965722d7365727665726c6573733f7374796c653d736f6369616c"&gt;&lt;img src="https://camo.githubusercontent.com/0a5dfffb381671ce814745108c72a532c2896b8bb03017b7c4e1efb926b55d61/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f666f726b732f6b6568696e64656162697577612d646f74636f6d2f6177732d74687265652d746965722d7365727665726c6573733f7374796c653d736f6369616c" alt="GitHub forks"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/kehindeabiuwa-dotcom/aws-three-tier-serverless/main/diagrams/part-4-full-architecture.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fkehindeabiuwa-dotcom%2Faws-three-tier-serverless%2Fmain%2Fdiagrams%2Fpart-4-full-architecture.png" alt="Three-tier serverless architecture on AWS — S3 and CloudFront, API Gateway and Lambda, DynamoDB"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;📖 &lt;strong&gt;Full walkthrough:&lt;/strong&gt; &lt;a href="https://dev.to/kehindeabiuwadotcom/how-i-delivered-a-static-website-globally-with-amazon-s3-and-cloudfront-and-the-security-mistake-i-2n38" rel="nofollow"&gt;4-part serverless series on Dev.to&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;⭐ &lt;strong&gt;Found this useful?&lt;/strong&gt; Star the repo — it helps others discover it and motivates the next part of the series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────┐
│                        PRESENTATION TIER                        │
│                                                                 │
│   Browser  ──►  CloudFront (CDN, HTTPS, edge caching)          │
│                      │  Origin Access Control (OAC / SigV4)    │
│                      ▼                                          │
│              S3 Bucket (private, Block All Public Access ON)    │
│              [index.html · style.css · script.js]               │
└─────────────────────────────────────────────────────────────────┘
                         │  GET /users?userId={id}
                         ▼
┌─────────────────────────────────────────────────────────────────┐
│                          LOGIC TIER                             │
│                                                                 │
│   API Gateway (REST API, /prod stage)                          │
│        │  Lambda Proxy Integration                              │
│        ▼                                                        │
│   Lambda: RetrieveUserData (Node.js 22, ESM)                   │
│&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/kehindeabiuwa-dotcom/aws-three-tier-serverless" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;&lt;em&gt;Kehinde Abiuwa — AWS Certified Solutions Architect (Professional) | Microsoft AZ-305&lt;/em&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://www.linkedin.com/in/kehinde-abiuwa-b68087247" rel="noopener noreferrer"&gt;linkedin.com/in/kehinde-abiuwa-b68087247&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>lambda</category>
      <category>serverless</category>
      <category>apigateway</category>
    </item>
    <item>
      <title>How I Delivered a Static Website Globally with Amazon S3 and CloudFront (And the Security Mistake I Almost Made)</title>
      <dc:creator>Kehinde Abiuwa</dc:creator>
      <pubDate>Tue, 16 Jun 2026 23:01:01 +0000</pubDate>
      <link>https://dev.to/kehindeabiuwadotcom/how-i-delivered-a-static-website-globally-with-amazon-s3-and-cloudfront-and-the-security-mistake-i-2n38</link>
      <guid>https://dev.to/kehindeabiuwadotcom/how-i-delivered-a-static-website-globally-with-amazon-s3-and-cloudfront-and-the-security-mistake-i-2n38</guid>
      <description>&lt;p&gt;There is a very common shortcut that thousands of developers take when they want to host a static website on AWS. They create an S3 bucket, turn on "Static Website Hosting," flip off "Block Public Access," add a public-read bucket policy, and call it done.&lt;/p&gt;

&lt;p&gt;It works. The site loads. But you have just made your S3 bucket publicly listable to anyone on the internet.&lt;/p&gt;

&lt;p&gt;In this article — the first in a four-part series where I build a production-grade serverless web app on AWS — I am going to show you the right way to do it. We will use Amazon CloudFront as the delivery layer and configure Origin Access Control (OAC) so the S3 bucket stays completely private, while users still get a fast, HTTPS-secured experience from edge locations around the world.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Are Building in This Series
&lt;/h2&gt;

&lt;p&gt;By the end of Part 4, we will have a working three-tier web application:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Services&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Presentation (frontend)&lt;/td&gt;
&lt;td&gt;Amazon S3 + Amazon CloudFront&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logic (backend API)&lt;/td&gt;
&lt;td&gt;AWS Lambda + Amazon API Gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;Amazon DynamoDB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This first part covers the &lt;strong&gt;presentation tier&lt;/strong&gt; — getting the frontend hosted and delivered globally.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User's Browser
      │
      ▼
Amazon CloudFront  ──────────────────────────────────
  (Edge Location)                                    │ OAC (SigV4 signed)
      │                                              ▼
      │                                      Amazon S3 Bucket
      │                                      (private, Block All Public Access ON)
      │◄── cached hit (no S3 call needed) ──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight is this: &lt;strong&gt;CloudFront is the only entity that can read from the S3 bucket.&lt;/strong&gt; The bucket never needs to be public. This is enforced through Origin Access Control — a feature that makes CloudFront sign every request to S3 using AWS Signature Version 4, and a bucket policy that only permits requests that carry your specific distribution's ARN.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Create the S3 Bucket and Upload Your Files
&lt;/h2&gt;

&lt;p&gt;I created an S3 bucket and uploaded three files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.html&lt;/code&gt; — the page markup&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;style.css&lt;/code&gt; — styling&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;script.js&lt;/code&gt; — JavaScript that will eventually call our API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Critical: do not touch the Block Public Access settings.&lt;/strong&gt; Leave them all on. We will not need them off.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkutbimj057xd6r300il.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkutbimj057xd6r300il.png" alt="S3 bucket with index.html, style.css and script.js uploaded, Block Public Access on" width="800" height="438"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The bucket with all three files uploaded — Block Public Access left fully enabled.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 2 — Create the CloudFront Distribution
&lt;/h2&gt;

&lt;p&gt;In the CloudFront console, I created a new distribution with these settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Origin domain&lt;/td&gt;
&lt;td&gt;S3 bucket (regional domain)&lt;/td&gt;
&lt;td&gt;Use the regional domain, NOT the S3 website endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin access&lt;/td&gt;
&lt;td&gt;Origin Access Control (new OAC)&lt;/td&gt;
&lt;td&gt;This is what keeps the bucket private&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default root object&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;So the root URL &lt;code&gt;/&lt;/code&gt; serves the page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Viewer protocol policy&lt;/td&gt;
&lt;td&gt;Redirect HTTP to HTTPS&lt;/td&gt;
&lt;td&gt;Force TLS at the edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache policy&lt;/td&gt;
&lt;td&gt;CachingOptimized&lt;/td&gt;
&lt;td&gt;AWS-managed policy tuned for S3 origins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP versions&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;Multiplexing reduces page load time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foi0bqvroo01p14fboqvx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foi0bqvroo01p14fboqvx.png" alt="Creating the CloudFront distribution with Origin Access Control" width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Creating the distribution — origin access set to Origin Access Control (OAC), default root object index.html.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the regional domain and not the S3 website endpoint?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The S3 website endpoint (&lt;code&gt;your-bucket.s3-website.region.amazonaws.com&lt;/code&gt;) does not support OAC. If you set it as your CloudFront origin, you are forced to make the bucket publicly readable. The S3 regional domain (&lt;code&gt;your-bucket.s3.region.amazonaws.com&lt;/code&gt;) is what you need.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3 — Set Up Origin Access Control
&lt;/h2&gt;

&lt;p&gt;After creating the distribution, CloudFront shows you a banner: &lt;em&gt;"Update S3 bucket policy."&lt;/em&gt; Copy the generated policy. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AllowCloudFrontServicePrincipalReadOnly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cloudfront.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::your-bucket-name/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"AWS:SourceArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Condition&lt;/code&gt; block is the important part. It locks the permission down to &lt;strong&gt;your specific distribution&lt;/strong&gt; — not just any CloudFront distribution in the world. Even another distribution pointing at the same bucket would be rejected.&lt;/p&gt;

&lt;p&gt;Go to S3 → your bucket → Permissions → Bucket Policy → paste it in and save.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4rr0c19kdz00zkrc962u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4rr0c19kdz00zkrc962u.png" alt="OAC configuration and the generated S3 bucket policy applied in the console" width="800" height="415"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The OAC setup and the auto-generated bucket policy pasted into S3 → Permissions.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Error I Hit (and Why You Will Too)
&lt;/h2&gt;

&lt;p&gt;When I first set up the distribution, I left the origin access setting as &lt;em&gt;Public&lt;/em&gt;. I thought: CloudFront is in front, so surely it can just read the bucket?&lt;/p&gt;

&lt;p&gt;No. CloudFront fetches from S3 as an unauthenticated request when origin access is set to Public. If Block Public Access is on (which it should be), S3 returns a 403 AccessDenied. The CloudFront distribution just serves that 403 to your users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8v14ocfkergqkz1tmt5f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8v14ocfkergqkz1tmt5f.png" alt="CloudFront returning 403 AccessDenied from a private S3 bucket" width="800" height="308"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;403 AccessDenied — what CloudFront served while origin access was still set to Public.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The sequence of events that broke things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Distribution set to Public origin → CloudFront makes unsigned GET request to S3&lt;/li&gt;
&lt;li&gt;S3 sees no auth, checks bucket policy → no permission granted to anonymous principal&lt;/li&gt;
&lt;li&gt;S3 returns &lt;code&gt;403 AccessDenied&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CloudFront serves the 403 to the user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After switching to OAC and updating the bucket policy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CloudFront makes SigV4-signed GET request with distribution ARN attached&lt;/li&gt;
&lt;li&gt;S3 checks bucket policy → sees &lt;code&gt;cloudfront.amazonaws.com&lt;/code&gt; principal + correct ARN → allows &lt;code&gt;s3:GetObject&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;S3 returns the file&lt;/li&gt;
&lt;li&gt;CloudFront caches it at the edge and serves it to the user&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Step 4 — Understanding CloudFront Caching
&lt;/h2&gt;

&lt;p&gt;Once the site was live, I ran a comparison between CloudFront delivery and the S3 website endpoint (which I temporarily enabled with a public-read policy just for benchmarking).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CloudFront was meaningfully faster&lt;/strong&gt; — especially on repeat visits, where it served assets directly from the edge cache with no trip to S3 at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F36umm2a2b50xjd9nrscw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F36umm2a2b50xjd9nrscw.png" alt="Latency comparison between CloudFront and the S3 website endpoint" width="799" height="384"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;CloudFront vs the raw S3 website endpoint — CloudFront wins, especially on cached repeat visits.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Geographic proximity:&lt;/strong&gt; CloudFront has over 450 edge locations globally. Instead of your user in Lagos hitting an S3 bucket in &lt;code&gt;eu-north-1&lt;/code&gt;, they get the response from the nearest edge node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/2 multiplexing:&lt;/strong&gt; Multiple assets (HTML, CSS, JS) download in parallel over a single connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression:&lt;/strong&gt; CloudFront automatically compresses with Gzip/Brotli.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache hits:&lt;/strong&gt; On a cache hit, S3 is never contacted. The latency is effectively the edge-to-user round trip only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;TTLs and Cache Invalidation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The CachingOptimized policy sets a default TTL of 86,400 seconds (24 hours). This means if you upload a new &lt;code&gt;index.html&lt;/code&gt;, users may see the old version for up to 24 hours unless you run a CloudFront invalidation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudfront create-invalidation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--distribution-id&lt;/span&gt; EDFDVBD6EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--paths&lt;/span&gt; &lt;span class="s2"&gt;"/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, you would version your assets (&lt;code&gt;main.abc123.js&lt;/code&gt;) so CloudFront can cache them indefinitely and only invalidate &lt;code&gt;index.html&lt;/code&gt; itself when a new deploy goes out.&lt;/p&gt;




&lt;h2&gt;
  
  
  S3 Static Hosting vs CloudFront — When to Use Each
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;S3 Website Endpoint&lt;/th&gt;
&lt;th&gt;CloudFront + OAC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS&lt;/td&gt;
&lt;td&gt;No (HTTP only)&lt;/td&gt;
&lt;td&gt;Yes (always)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bucket can be private&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global edge caching&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom domain + ACM&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAF / DDoS protection&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (AWS Shield)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signed URLs / Cookies&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;S3 static hosting is fine for:&lt;/strong&gt; a quick local demo, a throw-away prototype, or if you are the only person accessing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CloudFront is non-negotiable for:&lt;/strong&gt; anything user-facing, anything requiring HTTPS, anything global, anything you care about securing properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;In Part 2, we will build the logic tier: a Lambda function that queries DynamoDB and an API Gateway REST API that exposes it to the world. We will deal with IAM permissions, proxy integration, stages, and the CORS issues that show up the moment your frontend tries to call your API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 2 — APIs with Lambda + API Gateway — coming next in this series.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Diagram
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3mkf7jdqrs9eqtxmevwu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3mkf7jdqrs9eqtxmevwu.png" alt="Part 1 architecture — Browser to CloudFront edge to OAC to private S3 bucket" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All code for this series is available on GitHub: &lt;a href="https://github.com/kehindeabiuwa-dotcom/aws-three-tier-serverless" rel="noopener noreferrer"&gt;aws-three-tier-serverless&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Kehinde Abiuwa — AWS Certified Solutions Architect (Professional) | Microsoft AZ-305&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloudfront</category>
      <category>s3</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
