<?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: Roberto Belotti</title>
    <description>The latest articles on DEV Community by Roberto Belotti (@robertobelotti).</description>
    <link>https://dev.to/robertobelotti</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F104343%2Fef87763b-7cee-4fd6-a120-f448b72985be.jpeg</url>
      <title>DEV Community: Roberto Belotti</title>
      <link>https://dev.to/robertobelotti</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robertobelotti"/>
    <language>en</language>
    <item>
      <title>How I Locked Down a Static Site with Lambda@Edge and Cognito (No Backend Required)</title>
      <dc:creator>Roberto Belotti</dc:creator>
      <pubDate>Tue, 12 May 2026 11:30:00 +0000</pubDate>
      <link>https://dev.to/robertobelotti/how-i-locked-down-a-static-site-with-lambdaedge-and-cognito-no-backend-required-40el</link>
      <guid>https://dev.to/robertobelotti/how-i-locked-down-a-static-site-with-lambdaedge-and-cognito-no-backend-required-40el</guid>
      <description>&lt;p&gt;Your internal docs are wide open.&lt;/p&gt;

&lt;p&gt;That Docusaurus site you deployed to S3? The one with your API specs, runbooks, onboarding guides? Anyone with the URL can read it. S3 + CloudFront gives you HTTPS, caching, and global distribution out of the box. What it doesn't give you is a login page.&lt;/p&gt;

&lt;p&gt;Most teams solve this by moving docs to a platform (Notion, Confluence, whatever) and giving up control. Or they shove everything behind a VPN and call it a day. Both options work. Both have trade-offs that get annoying fast.&lt;/p&gt;

&lt;p&gt;I wanted a third option: keep the static site exactly as it is (Docusaurus in my case, but anything works), keep it on S3 + CloudFront (cheap, fast, zero maintenance), and add a real authentication layer in front of it without touching the site's code or build pipeline.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/biscolab/docusaurus-cognito-auth" rel="noopener noreferrer"&gt;docusaurus-cognito-auth&lt;/a&gt; — a fully serverless auth layer built with Lambda@Edge and AWS Cognito. This article is a walkthrough of the architecture, the decisions behind it, and the things that bit me along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack at a glance
&lt;/h2&gt;

&lt;p&gt;Four AWS services, each doing one thing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3&lt;/strong&gt; stores the static site files. Private bucket, no public access, no website hosting enabled. Just objects in a bucket. The bucket is provisioned empty by the stack — you deploy your static site separately with &lt;code&gt;aws s3 sync&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CloudFront&lt;/strong&gt; sits in front of S3 and handles everything HTTP: TLS termination, caching, compression, global edge distribution. It accesses S3 through an Origin Access Control (OAC), which means the bucket stays fully private. No public ACLs, no bucket policies leaking read access. CloudFront is the only thing that can read from S3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lambda@Edge&lt;/strong&gt; is where the auth logic lives. Two functions, both running at the CloudFront edge as viewer-request triggers. One checks the JWT cookie on every request. The other handles the OAuth callback after login. More on both in a moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cognito&lt;/strong&gt; is the identity provider. User Pool with Hosted UI — it handles signup, login, password reset, email verification. The Lambda functions talk to Cognito's token endpoint and validate JWTs against its JWKS.&lt;/p&gt;

&lt;p&gt;The key constraint: the auth layer and the static site are completely decoupled. You can swap the site (Docusaurus, Next.js export, plain HTML) without redeploying auth. You can update auth without rebuilding the site. Two independent concerns, two independent deploy cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request flow (when it actually matters)
&lt;/h2&gt;

&lt;p&gt;There are really only two scenarios. Understanding both is the key to understanding the whole system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 1: you have a valid cookie
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser → CloudFront → auth-check Lambda → "cookie is valid" → S3 → page served
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auth-check function extracts the &lt;code&gt;auth_token&lt;/code&gt; cookie, verifies the JWT signature against Cognito's JWKS (RS256), checks expiry, issuer, and audience. If everything passes, it returns the original CloudFront request object unchanged. CloudFront continues to S3, gets the page, serves it. The user never notices anything happened. This check takes about 1 ms at the edge once the JWKS keys are cached.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2: no cookie (or expired)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser → CloudFront → auth-check Lambda → 302 to Cognito login
User logs in on Cognito Hosted UI
Cognito → 302 to /callback?code=AUTH_CODE&amp;amp;state=/original-page
CloudFront → auth-callback Lambda → exchanges code for tokens → sets cookie → 302 to /original-page
Browser → (back to scenario 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the OAuth Authorization Code flow. The &lt;code&gt;state&lt;/code&gt; parameter carries the originally requested URL, so after login the user lands exactly where they intended. The cookie is &lt;code&gt;HttpOnly; Secure; SameSite=Lax&lt;/code&gt; — not accessible from JavaScript, transmitted only over HTTPS.&lt;/p&gt;

&lt;p&gt;Once the cookie is set, every subsequent request is scenario 1. No more redirects until the token expires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda@Edge: the part that makes it work (and the part that hurts)
&lt;/h2&gt;

&lt;p&gt;Lambda@Edge is powerful but comes with constraints that'll surprise you if you've only used regular Lambda.&lt;/p&gt;

&lt;h3&gt;
  
  
  No environment variables
&lt;/h3&gt;

&lt;p&gt;This is the big one. Lambda@Edge runs at CloudFront edge locations worldwide, and AWS decided that environment variables aren't supported. Period. So you can't do the normal thing (put your Cognito pool ID, client ID, and domain in env vars and read them at runtime).&lt;/p&gt;

&lt;p&gt;My solution: a &lt;code&gt;config.mjs&lt;/code&gt; file that gets its values baked in at build time. The deploy script reads the &lt;code&gt;.env&lt;/code&gt; file (which itself is auto-generated from CloudFormation outputs) and writes the actual values into the config before packaging the Lambda.&lt;/p&gt;

&lt;p&gt;It works. It's not elegant. But it's the only pattern that makes sense for Lambda@Edge.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 1 MB package limit
&lt;/h3&gt;

&lt;p&gt;Viewer-request functions have a 1 MB deployment package limit. This is why I chose the &lt;code&gt;jose&lt;/code&gt; library for JWT validation instead of something heavier. &lt;code&gt;jose&lt;/code&gt; is pure JavaScript (no native dependencies, no compiled bindings), handles JWKS fetching and caching automatically via &lt;code&gt;createRemoteJWKSet&lt;/code&gt;, and keeps the total bundle size well under the limit.&lt;/p&gt;

&lt;p&gt;If I'd gone with Python (my first instinct), PyJWT plus the &lt;code&gt;cryptography&lt;/code&gt; library for RS256 verification would have blown past 1 MB easily. JavaScript was the pragmatic choice here.&lt;/p&gt;

&lt;h3&gt;
  
  
  5-second timeout
&lt;/h3&gt;

&lt;p&gt;Viewer-request functions must respond within 5 seconds. The JWKS fetch (cold start only) and the token exchange in the callback both need to complete within this window. In practice it's never been an issue — Cognito's endpoints respond in under 200 ms — but it's something to be aware of if you add custom logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Must deploy to us-east-1
&lt;/h3&gt;

&lt;p&gt;Lambda@Edge functions must be created in us-east-1. AWS replicates them to edge locations globally, but the source must live in N. Virginia. The SAM template handles this, but if your default region is eu-central-1 (like mine), you need to be explicit in &lt;code&gt;samconfig.toml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Lambdas, not one
&lt;/h2&gt;

&lt;p&gt;I split the auth into two separate functions, wired to two separate CloudFront cache behaviors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;auth-check&lt;/strong&gt; is attached to the &lt;code&gt;DefaultCacheBehavior&lt;/code&gt; — it fires on every request to every path. Its job is simple: check the cookie, validate the JWT, pass through or redirect. It never talks to Cognito's token endpoint. It only reads the JWKS (and caches it in memory across warm invocations).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;auth-callback&lt;/strong&gt; is attached to a specific &lt;code&gt;CacheBehavior&lt;/code&gt; for the &lt;code&gt;/callback&lt;/code&gt; path. It only fires when Cognito redirects back after login. Its job is to exchange the authorization code for tokens (one POST to Cognito), set the cookie, and redirect the user.&lt;/p&gt;

&lt;p&gt;Why not one function that handles both? Separation of concerns. The auth-check function runs on every single request — it needs to be fast and lightweight. The callback function runs once per login session — it can afford the overhead of an HTTP call to Cognito. Mixing both flows into one handler would mean every request pays the cost of parsing callback logic it doesn't need.&lt;/p&gt;

&lt;p&gt;CloudFront evaluates &lt;code&gt;CacheBehaviors&lt;/code&gt; patterns before the &lt;code&gt;DefaultCacheBehavior&lt;/code&gt;, most-specific first. A request to &lt;code&gt;/callback&lt;/code&gt; matches the explicit path pattern and goes to auth-callback. Everything else falls through to auth-check. Clean routing, no conditionals in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SAM template: IaC for real
&lt;/h2&gt;

&lt;p&gt;The entire infrastructure (S3, CloudFront, Cognito, Lambda@Edge, IAM, OAC) is defined in a single &lt;code&gt;template.yaml&lt;/code&gt;. One &lt;code&gt;sam deploy&lt;/code&gt; and you have a working auth layer. Here are the things worth highlighting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAC over OAI.&lt;/strong&gt; Origin Access Control is the current AWS recommendation. Origin Access Identity (OAI) still works but is considered legacy. OAC uses SigV4 signing and supports more S3 features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-phase deploy.&lt;/strong&gt; The chicken-and-egg problem: the Cognito callback URL needs the CloudFront domain, but the CloudFront domain doesn't exist until the first deploy. The deploy script solves this by running an initial deploy with a placeholder callback URL, reading the CloudFront domain from CloudFormation outputs, updating &lt;code&gt;.env&lt;/code&gt;, and running a second deploy with the real URL. Subsequent deploys are single-pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching policies.&lt;/strong&gt; The default behavior uses AWS's managed &lt;code&gt;CachingOptimized&lt;/code&gt; policy (cache everything). The &lt;code&gt;/callback&lt;/code&gt; behavior uses &lt;code&gt;CachingDisabled&lt;/code&gt; (never cache the auth callback) plus an origin request policy that forwards query strings (the &lt;code&gt;code&lt;/code&gt; and &lt;code&gt;state&lt;/code&gt; parameters).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SPA error handling.&lt;/strong&gt; Custom error responses map 403 and 404 to &lt;code&gt;/index.html&lt;/code&gt; with a 200 status code. This lets client-side routing work after authentication (Docusaurus, React Router, etc.). Without this, a direct link to &lt;code&gt;/docs/some-page&lt;/code&gt; would return a 404 from S3 because there's no &lt;code&gt;/docs/some-page&lt;/code&gt; object — only &lt;code&gt;index.html&lt;/code&gt; that handles routing client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logout
&lt;/h2&gt;

&lt;p&gt;The auth-check Lambda also handles &lt;code&gt;/logout&lt;/code&gt; — no static file needed. When it sees a request to &lt;code&gt;/logout&lt;/code&gt;, it clears the &lt;code&gt;auth_token&lt;/code&gt; cookie (setting &lt;code&gt;Max-Age=0&lt;/code&gt;) and redirects to Cognito's logout endpoint, which invalidates the server-side session. Cognito then redirects back to the site root, and the next request triggers a fresh login.&lt;/p&gt;

&lt;p&gt;Adding a logout button to any static site is just an anchor tag: &lt;code&gt;&amp;lt;a href="/logout"&amp;gt;Logout&amp;lt;/a&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;This is one of the nice parts. For a typical internal docs site (let's say a few hundred users, a few thousand page views per day), the cost is effectively zero. All four services have generous free tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CloudFront: 1 TB transfer + 10 million requests/month free&lt;/li&gt;
&lt;li&gt;Lambda@Edge: 1 million requests + 400,000 GB-seconds/month free&lt;/li&gt;
&lt;li&gt;Cognito: 50,000 monthly active users free&lt;/li&gt;
&lt;li&gt;S3: 5 GB storage + 20,000 GET requests/month free&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even past the free tier, we're talking single-digit dollars per month. The most expensive component at scale is Cognito ($0.0055 per MAU after 50K), but if you have 50,000 people reading your internal docs, you have bigger problems to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CloudFront Functions instead of Lambda@Edge for auth-check.&lt;/strong&gt; CloudFront Functions run at the edge with sub-millisecond latency, support up to 10 million requests per second, and cost about one-sixth of &lt;a href="mailto:Lambda@Edge"&gt;Lambda@Edge&lt;/a&gt;. The limitation is that they can't make external network calls — which means no JWKS fetching. But if you pre-bake the JWKS public keys into the function code at build time (they rotate infrequently), you could do the entire JWT validation in a CloudFront Function. I might explore this for v2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom domain from day one.&lt;/strong&gt; The current setup uses the default CloudFront domain (&lt;code&gt;d1234abcd.cloudfront.net&lt;/code&gt;). Adding a custom domain (with ACM certificate) is straightforward but isn't included in the template to keep the initial setup simple. For production use, you'd want this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The full project is on GitHub: &lt;a href="https://github.com/biscolab/docusaurus-cognito-auth" rel="noopener noreferrer"&gt;biscolab/docusaurus-cognito-auth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clone, configure &lt;code&gt;samconfig.toml&lt;/code&gt;, run &lt;code&gt;npm run deploy&lt;/code&gt;, upload your site. The README covers everything including GitHub Actions CI/CD with OIDC (no long-lived access keys).&lt;/p&gt;

&lt;p&gt;If you're running internal docs on S3 without auth today, this gets you to enterprise-grade access control in about 15 minutes. And you keep full control of your infrastructure.&lt;/p&gt;




&lt;p&gt;What's your setup for protecting internal docs? VPN, platform, or something custom? Always curious to hear how other teams handle this.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>security</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
