Your internal docs are wide open.
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.
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.
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.
The result is docusaurus-cognito-auth — 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.
The stack at a glance
Four AWS services, each doing one thing:
S3 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 aws s3 sync.
CloudFront 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.
Lambda@Edge 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.
Cognito 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.
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.
The request flow (when it actually matters)
There are really only two scenarios. Understanding both is the key to understanding the whole system.
Scenario 1: you have a valid cookie
Browser → CloudFront → auth-check Lambda → "cookie is valid" → S3 → page served
The auth-check function extracts the auth_token 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.
Scenario 2: no cookie (or expired)
Browser → CloudFront → auth-check Lambda → 302 to Cognito login
User logs in on Cognito Hosted UI
Cognito → 302 to /callback?code=AUTH_CODE&state=/original-page
CloudFront → auth-callback Lambda → exchanges code for tokens → sets cookie → 302 to /original-page
Browser → (back to scenario 1)
This is the OAuth Authorization Code flow. The state parameter carries the originally requested URL, so after login the user lands exactly where they intended. The cookie is HttpOnly; Secure; SameSite=Lax — not accessible from JavaScript, transmitted only over HTTPS.
Once the cookie is set, every subsequent request is scenario 1. No more redirects until the token expires.
Lambda@Edge: the part that makes it work (and the part that hurts)
Lambda@Edge is powerful but comes with constraints that'll surprise you if you've only used regular Lambda.
No environment variables
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).
My solution: a config.mjs file that gets its values baked in at build time. The deploy script reads the .env file (which itself is auto-generated from CloudFormation outputs) and writes the actual values into the config before packaging the Lambda.
It works. It's not elegant. But it's the only pattern that makes sense for Lambda@Edge.
The 1 MB package limit
Viewer-request functions have a 1 MB deployment package limit. This is why I chose the jose library for JWT validation instead of something heavier. jose is pure JavaScript (no native dependencies, no compiled bindings), handles JWKS fetching and caching automatically via createRemoteJWKSet, and keeps the total bundle size well under the limit.
If I'd gone with Python (my first instinct), PyJWT plus the cryptography library for RS256 verification would have blown past 1 MB easily. JavaScript was the pragmatic choice here.
5-second timeout
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.
Must deploy to us-east-1
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 samconfig.toml.
Two Lambdas, not one
I split the auth into two separate functions, wired to two separate CloudFront cache behaviors:
auth-check is attached to the DefaultCacheBehavior — 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).
auth-callback is attached to a specific CacheBehavior for the /callback 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.
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.
CloudFront evaluates CacheBehaviors patterns before the DefaultCacheBehavior, most-specific first. A request to /callback matches the explicit path pattern and goes to auth-callback. Everything else falls through to auth-check. Clean routing, no conditionals in code.
The SAM template: IaC for real
The entire infrastructure (S3, CloudFront, Cognito, Lambda@Edge, IAM, OAC) is defined in a single template.yaml. One sam deploy and you have a working auth layer. Here are the things worth highlighting:
OAC over OAI. 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.
Two-phase deploy. 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 .env, and running a second deploy with the real URL. Subsequent deploys are single-pass.
Caching policies. The default behavior uses AWS's managed CachingOptimized policy (cache everything). The /callback behavior uses CachingDisabled (never cache the auth callback) plus an origin request policy that forwards query strings (the code and state parameters).
SPA error handling. Custom error responses map 403 and 404 to /index.html with a 200 status code. This lets client-side routing work after authentication (Docusaurus, React Router, etc.). Without this, a direct link to /docs/some-page would return a 404 from S3 because there's no /docs/some-page object — only index.html that handles routing client-side.
Logout
The auth-check Lambda also handles /logout — no static file needed. When it sees a request to /logout, it clears the auth_token cookie (setting Max-Age=0) 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.
Adding a logout button to any static site is just an anchor tag: <a href="/logout">Logout</a>.
Cost
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:
- CloudFront: 1 TB transfer + 10 million requests/month free
- Lambda@Edge: 1 million requests + 400,000 GB-seconds/month free
- Cognito: 50,000 monthly active users free
- S3: 5 GB storage + 20,000 GET requests/month free
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.
What I'd do differently
CloudFront Functions instead of Lambda@Edge for auth-check. CloudFront Functions run at the edge with sub-millisecond latency, support up to 10 million requests per second, and cost about one-sixth of Lambda@Edge. 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.
Custom domain from day one. The current setup uses the default CloudFront domain (d1234abcd.cloudfront.net). 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.
Try it
The full project is on GitHub: biscolab/docusaurus-cognito-auth
Clone, configure samconfig.toml, run npm run deploy, upload your site. The README covers everything including GitHub Actions CI/CD with OIDC (no long-lived access keys).
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.
What's your setup for protecting internal docs? VPN, platform, or something custom? Always curious to hear how other teams handle this.
Top comments (0)