Server-side proxy for address autocomplete + map positioning, built with Amazon Location Service and MapLibre GL JS. Routes search and map-tile requests to the Amazon Location Service (ALS) region that best matches the request's country — while keeping every API key off the client.
Zero API Key Exposure: Official Amazon Location Service examples typically embed the API Key directly in frontend code, making it visible to anyone via browser DevTools. This project uses a dual-layer architecture — server-side proxy + CloudFront edge injection — to ensure the API Key never reaches the client, eliminating the risk of key leakage.
- Address search requests are proxied through the Express backend; the API Key is used only on the server side
- Map tile requests go through CloudFront, where a CloudFront Function injects the API Key at the edge; browser requests carry no credentials
- The style descriptor is fetched server-side, tile URLs are rewritten, and all key parameters are stripped before returning to the frontend
- The API Key is stored in AWS Secrets Manager for secure rotation
Multi-Region Routing: Different ALS regions carry different map data providers and region-specific optimizations (e.g. GrabMaps in Southeast Asia). This proxy routes each request to the region matching its country, so users get the best local map/search quality. Routing is driven by a country→region mapping and can be toggled on/off with a single switch.
Auto Scaling to Reduce Cost: The production environment uses ECS Fargate with Auto Scaling, automatically adjusting the number of service instances based on CPU utilization. Scales down to minimum instances during low traffic and scales out during peak hours, avoiding the cost of fixed capacity. CloudFront edge caching further reduces origin requests and API call costs.
- 🔍 Address search autocomplete (Amazon Location Service Geo Places API)
- 📍 Browser geolocation (Geolocation API)
- 🗺️ Interactive map display (MapLibre GL + Amazon Location Map Tiles)
- 🎨 Map style switching (Standard / Monochrome / Hybrid / Satellite)
- 🌏 Country-based routing to the matching ALS region (search + map tiles)
Each request resolves to a target region, then the proxy calls that region's ALS endpoint with that region's key:
Request
├── has countryCode → look up region in the mapping → target region
├── has lat/lng, no country → reverse-geocode via home region → country → mapping → target region
└── nothing / no match / error → home region (fallback)
- The country→region mapping (
config/country-region-map.json) is the single source of truth: its top-level keys are the region set (every key becomes a CloudFront behavior, needs an API key, and is validated at startup), and one region is marked home with a"*"in its country list. - The home region is the default/guide region: it reverse-geocodes coordinates into a country and is the fallback whenever a country has no mapping. It is chosen by putting
"*"in that region's country list (e.g."ap-northeast-1": ["*"], or["*", "JP"]if it also serves specific countries). Exactly one region must be marked. There is noHOME_REGIONenvironment variable — the mapping defines it. - The home region must be a global-coverage region when routing is on (not a GrabMaps region —
ap-southeast-1/ap-southeast-5), because reverse-geocoding a worldwide coordinate needs global coverage. - With routing off, every request goes to the home region — identical to a plain single-region proxy.
Browser
├── Address search → Express backend → ALS Geo Places (target region, server-side key)
├── Map style → Express backend (fetch descriptor + rewrite tile URLs to CloudFront)
└── Map tiles → CloudFront /<region>/* (edge cache + inject that region's key) → ALS Geo Maps
CORS (/api) — Default closed: with ALLOWED_ORIGINS unset, no CORS headers are sent, so only same-origin callers work. Because the proxy is the deliverable and a customer may host their own frontend on a different origin, set ALLOWED_ORIGINS to opt those origins in. Avoid * in production — it lets any website call your API and burn the account's ALS quota. The API carries no cookies/credentials (keys stay server-side), so this is about who may call, not CSRF.
Rate limiting (/api) — A baseline per-source-IP token bucket (API_RATE_PER_SEC sustained + API_RATE_BURST spike) throttles abuse; /health is exempt so the ALB health check is never throttled. It is a floor, not a WAF replacement: per-task (in-memory), so with N ECS tasks the global rate is up to N × API_RATE_PER_SEC. For a global cap at the CloudFront edge, attach the optional WAF rule (step 4). TRUST_PROXY=2 (the CloudFront→ALB hop count) keys the limiter on the real, unspoofable client IP, not the ALB's.
CloudFront → ALB origin protection — The ALB is internet-facing, but two controls keep it reachable only through this distribution: its security group admits only the CloudFront managed prefix list, and its listener returns 403 unless the request carries the X-Origin-Verify shared-secret header this distribution injects (OriginVerifySecret). Because the prefix list spans all CloudFront distributions, the header is what stops someone pointing their own distribution at your ALB's DNS. (Health checks hit the target directly, so they're unaffected.)
CloudFront → ALB origin traffic (plaintext) — Viewer HTTPS terminates at CloudFront; the CloudFront→ALB hop is HTTP but travels the AWS backbone, not the public internet. So the residual exposure is plaintext query terms/coordinates on AWS infrastructure — never credentials (neither the ALS key nor the X-Origin-Verify secret leaves the backbone). We leave it plaintext by default; to encrypt it, put an ACM cert on a custom ALB domain and point CloudFront's origin at it (the default *.elb.amazonaws.com name can't hold an ACM cert).
Security response headers — Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', and Referrer-Policy: no-referrer. The CSP is limited to frame-ancestors because the API returns JSON; a frontend embedding it applies its own full CSP.
- An AWS account with Amazon Location Service enabled
- One Location Service API Key per region in the mapping (each key needs both
geo-placesandgeo-mapspermissions). Keys are created per region because an ALS API key is region-scoped. node(v20+) and theawsCLI (configured with credentials).
Two region concepts are used throughout and are intentionally kept distinct:
| Term | Meaning |
|---|---|
deploy region (<deploy-region>) |
The single region that hosts your infrastructure: the Secrets Manager secret, the CloudFront stack, the ECS stack, and the ECR repository. |
ALS region (<als-region>) |
A region the proxy routes to — one of the keys in the mapping file. There can be several. |
The home region and the deploy region are independent: they can be the same or different (e.g. deploy in Singapore for latency, but mark ap-northeast-1 as home with "*" because Singapore is a GrabMaps region and can't be the reverse-geocode guide).
| Variable | Description | Example |
|---|---|---|
MULTI_REGION_ROUTING |
Master switch; defaults to false (single-region: everything uses the home region). |
true |
LOCATION_API_KEYS |
JSON of region→API Key (each key has geo-places + geo-maps). Provided as one Secrets Manager secret. | {"ap-northeast-1":"v1.public.xxx"} |
MAP_TILE_DOMAIN |
CloudFront map-tile domain (output of the CloudFront stack). | d123.cloudfront.net |
ALLOWED_ORIGINS |
CORS allowlist for /api (comma-separated). Default closed: unset ⇒ no CORS headers ⇒ same-origin only. Use * to allow any origin. |
https://app.example.com |
API_RATE_PER_SEC |
Sustained per-source-IP request rate for /api (token-bucket refill). Default 10. |
20 |
API_RATE_BURST |
Per-source-IP burst capacity for /api (bucket size). Default 20. |
40 |
TRUST_PROXY |
Express trust proxy value — the proxy hop count so the rate limiter sees the real, unspoofable client IP. Defaults to false (fail-safe: an unset/empty or unrecognized value won't trust a client-supplied X-Forwarded-For, so the limiter can't be dodged; a bad value also warns). Behind CloudFront→ALB set it to 2 (the hop count) — the ECS stack passes TrustProxy=2 by default. |
2 |
REVERSE_GEO_CACHE_MAX |
FIFO cap for the per-task reverse-geocode grid cache (backstop against unbounded growth). Default 200000. |
200000 |
The regions in use and the home region are not environment variables — they come from
config/country-region-map.json(its keys, and the"*"-marked region). Locally the variables above are set viaexport; in production they are passed as CloudFormation parameters (see below). The two mechanisms are independent.
config/country-region-map.json is region-first ({ region: [ISO country codes] }) and is the single source of truth for both the region set and the home region:
{
"ap-northeast-1": ["*", "JP", "KR"],
"ap-southeast-1": ["SG", "MY", "TH", "ID", "VN", "PH"],
"eu-central-1": ["DE", "FR", "NL", "IE", "ES", "IT"],
"us-east-1": ["US", "CA", "MX"]
}- Each top-level key is an in-use region (gets a CloudFront behavior, needs a key, is validated at startup).
- Exactly one region must include
"*"in its country list — that marks the home region (fallback + reverse-geocode guide). It may also serve specific countries (as above) or be fallback-only ("ap-northeast-1": ["*"]). - A country not listed anywhere falls back to the home region at request time (logged, never an error).
The real config/country-region-map.json is gitignored (the country list is deployment-specific). The repo tracks config/country-region-map-example.json. Create your real file from it:
cp config/country-region-map-example.json config/country-region-map.json- Multi-region: edit the copy to match your business (regions + countries), keeping exactly one region marked
"*"as home. - Single-region: keep just one region, marked home, e.g.
{ "us-east-1": ["*"] }(all countries fall back to it).
The file is required at runtime: the app reads it on startup and fails fast if missing (the error tells you to run the cp above). docker build bakes it into the image, so it must exist before building too.
The steps below are the same for single-region and multi-region. Where they differ, a Single-region / Multi-region note calls it out. Throughout, <deploy-region> is your one infrastructure region and <als-region> is a region from the mapping.
cp config/country-region-map-example.json config/country-region-map.json
# then edit config/country-region-map.json for your regions/countries- Single-region: reduce it to one region, marked home, e.g.
{ "us-east-1": ["*"] }. - Multi-region: list each region and its countries; mark exactly one region home with
"*".
An ALS API key is region-scoped, so you need one key per region in the mapping. The helper script derives the region set from the mapping file, creates a key in each, assembles the LOCATION_API_KEYS JSON, and (with --write-secret) stores it in Secrets Manager:
scripts/create-api-keys.sh \
--mapping-file config/country-region-map.json \
--secret-name location-proxy/api-keys \
--deploy-region <deploy-region> \
--write-secretWithout --write-secret the script only prints the JSON to stdout so you can review it first. It creates one key named ProxyApiKey-<als-region> per region. This works for both single- and multi-region (single-region just has one region → one key).
Prefer to do it manually?
Run once per <als-region> in your mapping, then combine the keys into one JSON secret yourself:
aws location create-key \
--key-name ProxyApiKey-<als-region> \
--restrictions '{
"AllowActions":["geo-places:*","geo-maps:*"],
"AllowResources":[
"arn:aws:geo-maps:<als-region>::provider/*",
"arn:aws:geo-places:<als-region>::provider/*"
]}' \
--expire-time "2028-08-08T08:08:08Z" \
--region <als-region>
aws secretsmanager create-secret \
--name location-proxy/api-keys \
--secret-string '{"<als-region-1>":"v1.public.xxx","<als-region-2>":"v1.public.yyy"}' \
--region <deploy-region>The CloudFront template infra/cloudfront-map-proxy.yaml is generated from the mapping file (per region: an origin, a /<region>/* behavior, and a CloudFront Function that injects that region's key at the edge and strips the region path prefix):
node scripts/generate-cloudfront-template.jsThe template's CachePolicy / OriginRequestPolicy names are account-global (CloudFront is a global service), so the generator appends a short random suffix to avoid AlreadyExists collisions across stacks, regions, or redeploys. Set POLICY_NAME_SUFFIX to pin it (e.g. for reproducible output): POLICY_NAME_SUFFIX=v1 node scripts/generate-cloudfront-template.js.
The generated template reads the JSON secret directly via {{resolve:secretsmanager:location-proxy/api-keys:SecretString:<region>}}, so no parameters are needed. Deploy it in your deploy region:
aws cloudformation deploy \
--template-file infra/cloudfront-map-proxy.yaml \
--stack-name location-map-proxy \
--region <deploy-region>Get the CloudFront domain (this is your MAP_TILE_DOMAIN):
aws cloudformation describe-stacks \
--stack-name location-map-proxy \
--query 'Stacks[0].Outputs[?OutputKey==`CloudFrontDomain`].OutputValue' \
--output text \
--region <deploy-region>npm install
# (mapping file already created in step 0; the "*"-marked region is the home region)
export MULTI_REGION_ROUTING=true # single-region: false
export LOCATION_API_KEYS='{"ap-northeast-1":"v1.public.xxx","eu-central-1":"v1.public.yyy"}'
export MAP_TILE_DOMAIN=d1234567890.cloudfront.net # from step 2
npm start
LOCATION_API_KEYSmust contain an entry for every region in your mapping file (the example above shows two regions — match yours). A missing key fails startup validation. Thecreate-api-keys.shscript in step 1 assembles this JSON for you.
- Single-region: set
MULTI_REGION_ROUTING=falseand put a single region in the mapping and inLOCATION_API_KEYS.
High-availability architecture: CloudFront edge acceleration + ALB + multi-AZ ECS Fargate + Auto Scaling. Configuration is passed as CloudFormation parameters (not export).
Browser
├── Static assets → CloudFront (edge cache) → ALB → ECS Fargate
├── API requests → CloudFront (AWS backbone) → ALB → ECS Fargate → Amazon Location Service
└── Map tiles → CloudFront (edge cache + inject API Key) → Amazon Location Service
a. Create an ECR repository and push the image (in your deploy region):
aws ecr create-repository --repository-name location-proxy --region <deploy-region>
# Log in to ECR
aws ecr get-login-password --region <deploy-region> | \
docker login --username AWS --password-stdin <ACCOUNT_ID>.dkr.ecr.<deploy-region>.amazonaws.com
# Build and push (config/country-region-map.json must exist — it is baked into the image)
docker build -t location-proxy .
docker tag location-proxy:latest <ACCOUNT_ID>.dkr.ecr.<deploy-region>.amazonaws.com/location-proxy:latest
docker push <ACCOUNT_ID>.dkr.ecr.<deploy-region>.amazonaws.com/location-proxy:latestb. Deploy the ECS Fargate stack (in your deploy region). The regions and the home region both come from the mapping file baked into the image, so only MultiRegionRouting / the secret / domain / image plus the origin-verify secret are passed. Generate a strong OriginVerifySecret first (it lets the ALB reject any request that didn't come through this CloudFront distribution):
ORIGIN_VERIFY_SECRET=$(openssl rand -hex 32)
aws cloudformation deploy \
--template-file infra/ecs-fargate.yaml \
--stack-name location-proxy-ecs \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
LocationApiKeysSecretArn=arn:aws:secretsmanager:<deploy-region>:<ACCOUNT_ID>:secret:location-proxy/api-keys-XXXXXX \
MultiRegionRouting=true \
MapTileDomain=d1234567890.cloudfront.net \
ImageUri=<ACCOUNT_ID>.dkr.ecr.<deploy-region>.amazonaws.com/location-proxy:latest \
OriginVerifySecret=$ORIGIN_VERIFY_SECRET \
--region <deploy-region>- Single-region: set
MultiRegionRouting=false(the home region is whatever you marked"*"in the mapping). OriginVerifySecret(required, min 16 chars): the shared secret CloudFront injects asX-Origin-Verify; the ALB 403s any request lacking it. Rotate by redeploying with a new value.- Optional protection params (sensible defaults, override as needed):
AllowedOrigins=https://app.example.com(CORS allowlist for/api, default same-origin only),ApiRatePerSec=10/ApiRateBurst=20(per-source-IP/apirate limit),TrustProxy=2(CloudFront→ALB hop count for the real client IP; default already2),WebACLArn=...(attach an edge WAF — see the optional step below). See API protection & security notes.
Redeploying code/mapping changes: if you rebuild and push to the same image tag (
:latest),cloudformation deploysees no parameter change and reports "No changes to deploy. Stack xxx is up to date" — the running tasks keep the old image. Two ways to actually roll out:
- Force a new deployment (simplest):
aws ecs update-service --cluster <cluster> --service <service> --force-new-deployment --region <deploy-region>(get<cluster>/<service>from the stack'sClusterName/ServiceNameoutputs).- Or push an immutable tag (e.g. a git SHA) and pass it as
ImageUri=...:<sha>— the changed parameter makes CloudFormation roll out automatically (recommended for production; also gives clean rollbacks).
c. Get the access URL:
aws cloudformation describe-stacks \
--stack-name location-proxy-ecs \
--query 'Stacks[0].Outputs[?OutputKey==`ServiceUrl`].OutputValue' \
--output text \
--region <deploy-region>A WAF rate-based rule caps requests per client IP at the CloudFront edge — a global backstop above the per-task in-app limiter. Skip this step to run with the in-app limiter only.
A CLOUDFRONT-scoped WebACL can only be created in us-east-1, so it lives in its own small stack (infra/waf-webacl.yaml) regardless of your deploy region. Deploy it there:
aws cloudformation deploy \
--template-file infra/waf-webacl.yaml \
--stack-name location-proxy-waf \
--parameter-overrides WafRateLimit=2000 \
--region us-east-1Get the WebACL ARN and re-run the ECS deploy (step b) with WebACLArn=<arn> added — CloudFront is global, so a us-east-1 WebACL attaches to the distribution even though the ECS stack is in your business region:
aws cloudformation describe-stacks \
--stack-name location-proxy-waf \
--query 'Stacks[0].Outputs[?OutputKey==`WebACLArn`].OutputValue' \
--output text --region us-east-1
WafRateLimitis requests per rolling 5-minute window per IP (default2000, min100).
Edit config/country-region-map.json, then rebuild and push the image. Roll it out with aws ecs update-service --force-new-deployment (or an immutable image tag — see the note under step b; cloudformation deploy alone does nothing when the tag is unchanged). ECS performs a zero-downtime rolling update. If you added a new region, first create its key and update the secret (rerun step 1) and regenerate + redeploy the CloudFront stack (step 2). A misconfigured mapping fails the startup validation, so the new task never becomes healthy and the old version keeps serving.
npm testSee CONTRIBUTING for more information.
This library is licensed under the MIT-0 License. See the LICENSE file.
⭐ If this project is useful to you, please consider starring the repo — it helps others discover it.