Skip to content

Maps: authenticate Carto basemap tiles with CARTO_API - #36

Draft
aurph wants to merge 2 commits into
mainfrom
worktree-carto-api-key
Draft

aurph wants to merge 2 commits into
mainfrom
worktree-carto-api-key

Conversation

@aurph

@aurph aurph commented Sep 17, 2026

Copy link
Copy Markdown
Owner

The problem

Carto started watermarking anonymous basemap tiles in Aug 2026. It does not reject the request: it returns HTTP 200 with a valid PNG that has API KEY REQUIRED stamped diagonally across it. So there is no error anywhere, no failed request, nothing in the logs. Every tile on the Power Map, Compute Frontier, and My Grid is just defaced right now.

The fix

Read CARTO_API and append it to the tile URL as key=. Carto ignores api_key=, so the parameter name matters.

Without the env var, the maps render exactly as they do today. This is safe to merge and deploy before the secret is set, and independent of every other open PR.

Why a meta tag and not a VITE_ build var

  • Leaflet requests tiles from the browser, so a server-only secret never reaches them. The key is public by nature on any browser basemap. It should be domain-restricted in the Carto dashboard rather than treated as a secret.
  • index.html is already re-templated per request and served no-cache, so injecting there means rotating the key needs a restart, not a rebuild — and the env var keeps the plain name CARTO_API instead of VITE_CARTO_API.
  • It is in the DOM before the app module (deferred) runs, so there is no unkeyed first paint. That matters: Carto serves tiles with max-age=15552000, so a watermarked tile fetched once sticks in the browser cache for ~180 days.

Two bugs caught while verifying

  1. Production CSP is script-src 'self'. The first version injected an inline <script>. The HTML looked correct over curl, but the browser silently blocked it and the tiles went out unkeyed. Only a real browser against a production build surfaced this. A meta tag is subject to no such directive and needs no nonce or hash.
  2. maxZoom={undefined} is not the same as omitting it. react-leaflet forwards the key into Leaflet options, overriding GridLayer's default of 18 with undefined. My Grid sets no maxZoom of its own, so the layer became unbounded and threw Attempted to load an infinite number of tiles, rendering zero tiles. basemapTileLayerProps now omits the key entirely unless a value is given, with a test pinning it.

Changes

  • server/runtime-config.ts — reads and validates CARTO_API, injects the meta tag; warns on boot when unset or malformed
  • client/src/lib/basemap.ts — tile URL builder and TileLayer props
  • client/src/components/BasemapTiles.tsx — the one place all three maps get tiles
  • Wired into both HTML paths (server/static.ts, server/vite.ts)
  • .env.example + README env table

Verification

Typecheck clean, 511/511 tests pass (24 new), production build clean.

Driven in a real headless Chromium against both the dev server and a production build, capturing every outbound tile request:

Page With CARTO_API Without
Power Map 18/18 keyed 18/18 clean unkeyed
Compute Frontier 12/12 keyed 12/12 clean unkeyed
My Grid 15/15 keyed 15/15 clean unkeyed

Not verified: that a real key actually clears the watermark. That needs your key — an invalid key behaves identically to no key, so this is the one step I could not close. To confirm once the secret is set:

curl -s -o /tmp/t.png "https://a.basemaps.cartocdn.com/dark_nolabels/5/9/12.png?key=YOUR_KEY" && open /tmp/t.png

Clean tile = key is good. Watermarked = the key is not valid for basemaps.

After merge

Set CARTO_API in Replit secrets and redeploy. Hard-refresh once — watermarked tiles already cached in your browser persist for ~180 days otherwise.

🤖 Generated with Claude Code

https://claude.ai/code/session_016kmPCuF8Fs4byBLXK7CaKM

aurph and others added 2 commits September 17, 2026 15:06
Carto began watermarking anonymous basemap tiles in Aug 2026. The request
still returns HTTP 200 with a valid PNG, so nothing errors - every tile on
the Power Map, Compute Frontier, and My Grid just renders with "API KEY
REQUIRED" stamped diagonally across it.

Reads the key from CARTO_API and appends it to the tile URL as `key=`
(Carto ignores `api_key=`). Without the var the maps render exactly as they
do now, so this is safe to deploy before the secret is set.

The key is delivered as a meta tag injected into index.html at request time,
not a build-time VITE_ var and not an inline script:

- Leaflet requests tiles from the browser, so a server-only value never
  reaches them. The key is public by nature on any browser basemap; it
  should be domain-restricted in the Carto dashboard, not kept secret.
- index.html is already re-templated per request and served no-cache, so
  rotating the key needs a restart, not a rebuild.
- Production CSP is `script-src 'self'`, which blocks inline scripts. An
  inline config script looked correct over curl but was silently dropped by
  the browser; a meta tag is subject to no such directive and needs no
  nonce or hash.

All three maps now share one BasemapTiles component. It omits maxZoom rather
than passing undefined, because an explicit undefined overrides Leaflet's
GridLayer default of 18 and makes the layer unbounded - My Grid sets no
maxZoom of its own and threw "Attempted to load an infinite number of tiles".

Verified in a real browser against dev and production builds: all three maps
request keyed tiles (18/18, 12/12, 15/15) with the var set, and clean
unkeyed URLs without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kmPCuF8Fs4byBLXK7CaKM
Follows the conventions in CLAUDE.md, which I did not have when the first
commit on this branch was written:

- New files are kebab-case, so BasemapTiles.tsx becomes basemap-tiles.tsx
  and the three importing pages follow.
- CLAUDE.md now carries CARTO_API in the env list and Carto in the data
  source table, per "when this file and the code disagree, the code wins;
  fix this file in the same commit."

The CARTO_API note records the two traps, since neither is visible from
the call site: it is the only env var that reaches the browser, and an
inline script would be dropped by the production CSP while curl still
showed correct HTML.

Claude-Session: https://claude.ai/code/session_016kmPCuF8Fs4byBLXK7CaKM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant