Moving a Bundled API Key to CloudFront Edge
A practical account of discovering a NASA API key baked into the client bundle of a static site, and moving the key injection point to the AWS edge via CloudFront Functions — covering local vs production flow and full AWS setup steps.
Starting Point — What Was Wrong
For static sites calling external APIs, there are traditionally two choices.
- Expose the key via
NEXT_PUBLIC_*and call the API directly from the browser - Add a server-side proxy that injects the key
Option 1 is convenient but publishes the key. Option 2 needs a server. With output: 'export', option 2 seems impossible — which is where the common wisdom stops. But as CloudFront added edge functions, a third option emerged: "static deployment with key injection at the edge."
This post documents applying that third option in production.
The Pre-existing Setup — Key Baked Into the Bundle
I was calling NASA's APOD and NeoWs APIs in three places.
/apod— Astronomy Picture of the Day/neo— Near-Earth Object monitor- Main home — APOD thumbnail
The page code looked like this:
const API_KEY = process.env.NEXT_PUBLIC_NASA_API_KEY ?? 'DEMO_KEY';
const APOD_URL = 'https://api.nasa.gov/planetary/apod';
fetch(`${APOD_URL}?api_key=${API_KEY}&date=${today}`);
Any env var with the NEXT_PUBLIC_* prefix is inlined into the client bundle at build time. Meaning: opening the output .js file, the api_key=xxxxxx string is right there verbatim.
Inspection — Confirming the Security Issue
Anyone could reproduce the exposure:
- Open
_next/static/chunks/*.jsin the browser Sources tab - Search for
api.nasa.gov→ the URL pattern shows up - Look nearby — the
api_key=query parameter holds a 40-character key
That key is an asset tied to my NASA account. If someone abused it:
- Rate limit exhaustion — real visitors would hit errors on
/apodand/neo - Policy violation → key blocked — the whole feature dies
NASA has no monetary billing for personal keys, but rate limits and blocking risks are real. In short: no cost, but service availability was in an attacker's hands.
How It Behaved in Local Dev vs Production
The same code ran in both environments but with very different exposure profiles.
Local Development
.env.localsetNEXT_PUBLIC_NASA_API_KEY, fed into build- Browser calls
api.nasa.govdirectly - Issue: key visible in DevTools — but only I was using the local server, so practical impact was nil
Production (S3 + CloudFront)
- GitLab CI injected
NEXT_PUBLIC_NASA_API_KEYat build - Bundles with the key inlined uploaded to S3
- Every visitor's browser called
api.nasa.govdirectly - Issue: anyone worldwide could lift the key via DevTools
Same design flaw in both environments, but production exposure was incomparably larger.
Solution — Inject the Key at the Edge
Three core ideas:
- The client never knows the key — every trace of
api_keyremoved from client code - Injection happens only right before the call reaches NASA — the key doesn't exist before that point
- The injection point is a CloudFront Function — a lightweight JS runtime at the edge
New flow:
[Browser]
GET https://js2devlog.com/api/nasa/apod?date=2026-04-19
↓
[CloudFront — Behavior: /api/nasa/*]
↓
[CloudFront Function: nasa-api-key-inject]
- URI rewrite: /api/nasa/apod → /planetary/apod
- Querystring add: api_key=<NASA_KEY>
↓
[Origin: api.nasa.gov]
GET /planetary/apod?date=2026-04-19&api_key=<NASA_KEY>
↓
[Response flows back to the browser]
Client code becomes much smaller:
const APOD_URL = '/api/nasa/apod'; // relative path — no domain
fetch(`${APOD_URL}?date=${today}`); // no api_key at all
The relative path /api/nasa/* itself becomes the contract — it says "CloudFront takes it from here".
What About Local Dev?
Static deployment has CloudFront, but the local dev server doesn't. The answer is Next.js rewrites():
// next.config.ts
async rewrites() {
return [{
source: '/api/nasa/apod',
destination: `https://api.nasa.gov/planetary/apod?api_key=${process.env.NASA_API_KEY}`,
}];
}
Key points:
rewrites()is dev-server-runtime only — it isn't included in anoutput: 'export'build artifact- So in production this config doesn't fire — CloudFront plays that role instead
- Only in dev does
.env.local'sNASA_API_KEYactually get used - Never use the
NEXT_PUBLIC_prefix — that immediately brings back build-time inlining
Final Per-Environment Behavior
| Env | Injection point | Key source | Client exposure |
|---|---|---|---|
| Local dev | Next.js dev server (rewrites) | .env.local (server-only var) | None |
| GitLab CI build | (no key needed at build time in this setup) | — | — |
| Prod (S3 + CloudFront) | CloudFront Function (edge) | Hardcoded in Function code | None |
AWS Setup — Step by Step
AWS Console UI terminology is based on the Korean locale; English labels noted in parentheses.
1. Add an Origin
CloudFront → Distributions → (select your distribution) → Origins → Create Origin
| Field | Value |
|---|---|
| Origin domain | api.nasa.gov |
| Protocol | HTTPS only |
| Minimum Origin SSL Protocol | TLSv1.2 |
| Origin Path | (empty) |
| Name | nasa-api |
Save.
2. Create + Publish a CloudFront Function
CloudFront → Functions → Create function
| Field | Value |
|---|---|
| Name | nasa-api-key-inject |
| Runtime | cloudfront-js-2.0 |
Code (Development tab):
function handler(event) {
var req = event.request;
var key = 'YOUR_NASA_API_KEY_HERE';
if (req.uri.startsWith('/api/nasa/neo/')) {
req.uri = '/neo/rest/v1/' + req.uri.substring('/api/nasa/neo/'.length);
} else if (req.uri === '/api/nasa/apod') {
req.uri = '/planetary/apod';
}
req.querystring['api_key'] = { value: key };
return req;
}
Save → Publish tab → Publish function — required.
⚠️ Easy to miss: without Publish, the function won't appear in the Behavior dropdown.
3. Add a Behavior
Distribution → Behaviors → Create behavior
| Field | Value |
|---|---|
| Path pattern | /api/nasa/* |
| Origin / Origin group | nasa-api (the origin you just created) |
| Viewer protocol policy | Redirect HTTP to HTTPS |
| Allowed HTTP methods | GET, HEAD |
| Restrict viewer access | No |
| Compress | Yes |
| Cache policy | CachingOptimized — APOD/NEO data changes daily, ideal match |
| Origin request policy | AllViewerExceptHostHeader ← required |
| Function associations — Viewer request | CloudFront Functions → nasa-api-key-inject |
Save. In the behavior list, precedence just needs to have specific paths above wildcards — any order works if they don't overlap.
If you leave origin request policy at
None, the query string isn't forwarded to the origin and NASA returnsAPI_KEY_MISSING. This is the most common mistake.
4. Revoke the Old Key + Issue a New One
The old key is already public, so retire it.
- Visit https://api.nasa.gov/ → fill the "Generate API Key" form at the top → receive a 40-char key by email
- Replace
'YOUR_NASA_API_KEY_HERE'in the Function with the new key → Save → Publish again
5. Clean Up GitLab CI/CD Variables
Settings → CI/CD → Variables
| Variable | Action |
|---|---|
NEXT_PUBLIC_NASA_API_KEY | Delete — this prefix inlines into the client bundle |
NASA_API_KEY | Register new key, check Protected + Masked |
- Protected: only available in protected branches/tags
- Masked: CI logs render it as
***
6. Update Local .env.local
# remove
NEXT_PUBLIC_NASA_API_KEY=xxx
# add (just rename, same key value)
NASA_API_KEY=new_key
Verification
Once the distribution state flips to "Deployed" (a few minutes):
curl -I "https://www.js2devlog.com/api/nasa/apod?date=2026-04-19"
Success: HTTP/2 200 + a JSON body ({"copyright":"...","title":"...",...}).
Failure hints:
| Symptom | Root cause |
|---|---|
{"error_message":"API_KEY_MISSING"} | Function not published / behavior not linked |
{"error":{"code":"API_KEY_INVALID"}} | Typo in the Function code's key string |
| 403 / 404 | Origin request policy isn't AllViewerExceptHostHeader |
In browser DevTools, the Network tab's Request URL should not show any api_key param — injection happens at the edge, so it doesn't exist on the client side. That's the point.
CloudFront Function — Limitations and Tradeoffs
The biggest constraint: the key sits as a string inside Function code. CloudFront Function's runtime is tight — it can't read AWS Secrets Manager or environment variables.
Exposure surface in this design:
- Regular users: not accessible (never appears in CloudFront responses)
- AWS Console accessors: visible when they open the Function
- Git repo: never present
If the team is large or compliance demands higher rigor, you can graduate to Lambda@Edge + Secrets Manager. Lambda@Edge runs Node.js and can query Secrets Manager, but adds cold-start latency in the hundreds of ms and extra billing. For a personal blog, CloudFront Function is enough.
Reusing the Pattern
This work completed /api/nasa/*, but the same structure applies to other external APIs:
/api/exoplanet→ NASA Exoplanet Archive (no key needed)/api/mars-rss→ NASA Mars RSS (no key, just URI rewrite)
Each gets its own Origin + Behavior + (if needed) Function. All client code stays unified on relative paths with no domains, and you get CORS bypass for free.
Retrospective
Three things worth taking away.
1. NEXT_PUBLIC_* literally means "public". The name looks like a prefix convention, but semantically it says "anyone can extract this value". Never put anything sensitive behind it.
2. Even static deployments can replace most backend needs with edge functions. CloudFront Functions / Lambda@Edge / Vercel Edge Functions — the options are plentiful. "It's static, so we can't" is an outdated premise.
3. Do security setup first, not last. This project ran with a public key for quite a while. That I wasn't abused was luck. Work like "moving a key" actually takes 30 minutes to an hour — once you find it, handling it immediately is the cheapest path.
Guestbook
Leave a short note about this post
Loading...