--- path: earth-engine-in-a-web-map title: "Earth Engine results in your own web map: four routes and their limits" desc: "Iframe an Earth Engine App, serve live map tiles, export static tiles, or use the Map Tiles API: what each needs for auth, billing, caching and terms." date: Oct 21, 2026 author: GISOwl Engineering category: Web mapping image: /images/blog/earth-engine-in-a-web-map.webp imageAlt: "Overhead view of a river delta meeting the sea, its branching channels and wetlands tinted yellow and teal, with sediment plumes swirling offshore." imageCaption: "Cover illustration; it does not show data from this article." --- # Earth Engine results in your own web map: four routes and their limits You have an Earth Engine analysis and a MapLibre, Leaflet or OpenLayers application, and you want the result inside your map, not on a Google page. There are four realistic routes, and they differ less in code than in who authenticates, who pays per tile and what the terms let you keep. We had no Earth Engine or Google Cloud credentials for this post. Everything below is either read from Google's documentation on 21 October 2026, or observed anonymously: response headers, a public Earth Engine App loaded in Playwright 1.58.2 (Chromium 145.0.7632.6) on an M1 Pro over home internet, and public sample tiles. The server example was written against the REST reference and the earthengine-api 1.7.49 source. We did not run it against a live project. ## The four routes side by side | | 1. App in an iframe | 2. Live tiles from `maps.create` | 3. `Export.map.toCloudStorage` | 4. Map Tiles API (2D) | |---|---|---|---|---| | What the browser shows | A Google-hosted page with its own Google Maps basemap | Your EE result as XYZ tiles in your map | Your EE result as static XYZ files | Google's roadmap or satellite basemap, not your result | | Auth | None for public apps; Google sign-in for Group-restricted apps | Service account on your server; the tile URL itself acts as a token | None if objects are public; CORS set per bucket | API key plus a session token | | Who pays | The app's Cloud project (EECU quota or bill) | Your project, online EECUs for every tile computed | Batch EECUs once, then Cloud Storage | Maps Platform 2D Map Tiles SKU | | Caching | Not yours to control | Tiles came back `private, max-age=3600` | Your files; sample bucket served `public, max-age=3600` | Only as the `Cache-Control` and `ETag` headers allow | | Lifetime | While the app is published | Map ID valid "a few hours" | Until you delete the objects | Session token about two weeks | | Fits | Sharing a finished tool quickly | Results that change with parameters | A fixed result, many viewers | A Google basemap under routes 2 or 3 | ## Route 1: an Earth Engine App in an iframe Apps are published from the Code Editor and served at `USERNAME.users.earthengine.app` or `PROJECT-ID.projects.earthengine.app`. [The Apps guide](https://developers.google.com/earth-engine/guides/apps) says viewers need no Earth Engine account, access can be public or limited to a single Google Group, and any assets the app reads must be shared publicly or with the app. The guide does not mention iframes. So we checked the headers. `google.earthengine.app/view/split-panel`, `/ocean`, `/forest-change` and `www.earthengine.app` all returned no `X-Frame-Options` and no `Content-Security-Policy`. Framed inside a page served from `http://127.0.0.1`, the split-panel app rendered normally: ![The Google-published Split Panel Earth Engine App, Sentinel-1 composites of 2018 flooding in Myanmar, rendered inside an iframe on a localhost page](/images/blog/earth-engine-in-a-web-map/ee-app-in-iframe.webp) *Screenshot of Google's public Split Panel app. Contains modified Copernicus Sentinel data (2018); basemap © Google.* Recording the app's traffic shows how a public app talks to Earth Engine. The page HTML carries a `ya29.` OAuth access token with `authTokenExpiresInMs: 3599000`, about an hour. The app then makes one authenticated `GET .../algorithms` and two `POST https://content-earthengine.googleapis.com/v1/projects/earthengine-legacy/maps` calls, one per panel, with a `Bearer` header. The 48 tile requests that follow go to `earthengine.googleapis.com` with no `Authorization` header at all. This matches the [access-control guide](https://developers.google.com/earth-engine/guides/access_control): each app has its own service account, and for public apps `earth-engine-public-apps@appspot.gserviceaccount.com` holds Token Creator on it. Framed and top-level loads made the same calls. The basemap is the Google Maps JavaScript API (v3.64, Google's key), and the app's script source is a public JSON file, as the Apps FAQ warns. Billing follows the app's parent project. The [noncommercial tiers page](https://developers.google.com/earth-engine/guides/noncommercial_tiers) says app usage counts against that project, and apps slow down when it runs out of monthly quota. The [pricing page](https://cloud.google.com/earth-engine/pricing) lists apps among the online EECU consumers, so a commercial project pays per EECU-hour for app traffic. Apps are also not supported inside a VPC Service Controls perimeter. Where it breaks: the app is a separate map. You cannot add its layer to your MapLibre style or swap its basemap. Iframe embedding is undocumented, so Google could add `frame-ancestors` tomorrow and your page would show a blank box. We did not test a Group-restricted app; it needs a Google sign-in, and third-party cookie blocking in a cross-site frame is a likely failure point. ## Route 2: live tiles from `maps.create` This is what `getMapId` does under the hood. [`projects.maps.create`](https://developers.google.com/earth-engine/reference/rest/v1/projects.maps/create) (also under `projects.locations.maps`) takes an `EarthEngineMap` body with `expression`, `fileFormat`, `bandIds` and `visualizationOptions`, needs the `earthengine.maps.create` permission and one of the `earthengine`, `earthengine.readonly`, `cloud-platform` or `cloud-platform.read-only` scopes, and returns a map name. The reference says the ID is valid for "a moderate period (a few hours)" and that JPEG or PNG output needs one or three bands, clipped to 0–255. Tiles come from [`projects.maps.tiles.get`](https://developers.google.com/earth-engine/reference/rest/v1/projects.maps.tiles/get): ```text GET https://earthengine.googleapis.com/v1/{map name}/tiles/{zoom}/{x}/{y} ``` In earthengine-api 1.7.49, `ee.data.getMapId` builds exactly that as `tile_fetcher.url_format`, with `{z}/{x}/{y}` placeholders, so the string drops straight into a MapLibre, Leaflet or OpenLayers XYZ source. What the tile endpoint did with the URL captured from the public app: - The map name had the form `maps/<32 hex>-<32 hex>`. Swapping in a fake ID returned `401 Invalid token`; dropping the second half returned a 500. The second half is the credential. - A plain `curl` with no credentials got a 256×256 JPEG with `Cache-Control: private, max-age=3600`, `Vary: Origin`, and `Access-Control-Allow-Origin` echoing whatever `Origin` we sent (`https://example.org`, `http://127.0.0.1:8000`). - The URL still served tiles 34 minutes after the app created it, when we stopped polling. We did not observe the expiry. So the URL is a bearer token. Anyone who copies it from your page's network tab can render tiles on your project's EECUs until it expires. One caveat: the client library's docstring says `url_format` "will require authorization" when no API key is set. Our observation covers a map created by a public app's service account, and we could not test one created by our own service account. Creating the map has to happen on a server. An anonymous `POST` to `maps.create` returned `401 CREDENTIALS_MISSING`, and putting a service account key in the browser would hand out your project. Per the [service account guide](https://developers.google.com/earth-engine/guides/service_account), the account needs the Earth Engine Resource Viewer role on a project that is registered for Earth Engine with the API enabled; the access-control guide adds Service Usage Consumer. The guide recommends Application Default Credentials over key files. There is also a contract question. The Earth Engine section of the [Google Cloud Service Specific Terms](https://cloud.google.com/terms/service-terms) says: "Customer may not allow End Users of Customer's Applications to directly access or interact with the Google Earth Engine APIs unless those End Users have their own Google Cloud Platform Accounts." Whether a browser fetching `earthengine.googleapis.com` tile URLs counts is not something we can settle. A tile proxy on your server avoids the question, hides the map ID and lets you rate-limit. Ask your Google contact before relying on direct tile URLs in a commercial product. Quota and cost: the [usage page](https://developers.google.com/earth-engine/guides/usage) sets per-project defaults of 40 concurrent requests on the standard endpoint and 100 requests per second, with HTTP 429 and client backoff when exceeded. Commercially, online and batch EECU-hours cost $0.40 per hour up to 10,000 hours a month per billing account, $0.28 to 500,000 and $0.16 above, on the Limited plan or after the credits in Basic ($500 a month) and Professional ($2,000 a month). Noncommercial projects get 150 EECU-hours a month (Community), 1,000 (Contributor, billing account required but not charged) or 100,000 (Partner, by application); past the limit, work runs in a slower restricted mode. Google says these limits have been rolling out since 27 April 2026. The [Earth Engine terms](https://earthengine.google.com/terms/) exclude selling a product or service, or charging for apps or data made with Earth Engine, from noncommercial use, so a paid product needs a commercial project. ### The server A minimal Flask endpoint. It caches the map ID for an hour, well inside "a few hours", and serves the JRC Global Surface Water occurrence band, whose [catalog page](https://developers.google.com/earth-engine/datasets/catalog/JRC_GSW1_4_GlobalSurfaceWater) asks for the attribution "Source: EC JRC/Google" on published maps. ```python import os from datetime import datetime, timedelta, timezone import ee import google.auth import requests from flask import Flask, Response, jsonify PROJECT = os.environ["EE_PROJECT"] # Cloud project registered for Earth Engine MAX_AGE = timedelta(hours=1) # maps.create: IDs are valid "a few hours" credentials, _ = google.auth.default( scopes=["https://www.googleapis.com/auth/earthengine"]) ee.Initialize(credentials, project=PROJECT) app = Flask(__name__, static_folder="static", static_url_path="") _current = {"tiles": None, "created": datetime.min.replace(tzinfo=timezone.utc)} def create_map() -> str: water = ee.Image("JRC/GSW1_4/GlobalSurfaceWater").select("occurrence") vis = {"min": 0, "max": 100, "palette": ["ffffff", "ffbbbb", "0000ff"]} # One POST to https://earthengine.googleapis.com/v1/projects/PROJECT/maps return water.getMapId(vis)["tile_fetcher"].url_format def current_template() -> str: now = datetime.now(timezone.utc) if now - _current["created"] > MAX_AGE: _current.update(tiles=create_map(), created=now) return _current["tiles"] @app.get("/api/ee-tiles") def ee_tiles(): template = current_template() return jsonify( tiles=[template], attribution="Source: EC JRC/Google", expiresAt=(_current["created"] + MAX_AGE).isoformat(), ) # Optional: proxy tiles so the browser never sees the map ID. # Point the client at request.host_url + "ee/{z}/{x}/{y}" instead. @app.get("/ee///") def ee_tile(z, x, y): upstream = requests.get(current_template().format(z=z, x=x, y=y), timeout=30) return Response( upstream.content, status=upstream.status_code, content_type=upstream.headers.get("Content-Type"), headers={"Cache-Control": upstream.headers.get("Cache-Control", "private")}, ) @app.get("/") def index(): return app.send_static_file("index.html") ``` To run it you need a Cloud project registered for Earth Engine with the API enabled, a service account with the roles above, ADC that resolve to it, `pip install earthengine-api flask requests`, and `EE_PROJECT=my-project flask --app server run`. With several workers you will create a few extra map IDs, which is harmless. ### The MapLibre client MapLibre GL JS 6 ships only as ES modules; the UMD `maplibre-gl.js` is gone, so v5-era snippets with a plain `