First, ask whether you need Google at all
Most of what my apps do runs on free services that need no key and no account: Photon for address search, Open-Meteo for weather, OpenStreetMap through Overpass for gas stations and viewpoints, and OSRM or BRouter for routes. Google only comes in where nothing free does the job. For gas prices in the US there simply is no free, keyless source, so Pump Fiction uses the fuelOptions field of the Places API. That one field is the whole reason the site has a Google key.
So the rule I ended up with: build the app on the free sources first, then add Google as an extra layer that switches on when a key is present.
1. Set up the project and the key
- Create a project in the Google Cloud console and add a billing account. Google Maps Platform won’t answer without one, even inside the free usage.
- Enable only the APIs you call. For this site that’s Places API (New) and Pollen API. Note the “New”: the older Places API is a different product with different URLs.
- Under APIs & Services → Credentials, create an API key. One key can serve several APIs.
2. Accept that a browser key is public
A key used from JavaScript is visible to anyone who opens the developer tools. There’s no hiding it in a static site, and scrambling it only makes your own debugging harder. What protects it is what Google lets the key do, which is the next section. (Or you keep it on your server instead, which is section 8.) Keeping it out of git is still worth it, because a key in a public repo gets scraped by bots within hours.
This is how the site started: each app had a config.json in the repo with an empty key.
{ "googleKey": "" }
The real key lives in a GitHub repository variable, and the deploy workflow writes it into the file just before uploading:
- name: Add the Google Maps key to the apps that use it
env:
GOOGLE_MAPS_KEY: ${{ vars.GOOGLE_MAPS_KEY }}
run: |
if [ -n "$GOOGLE_MAPS_KEY" ]; then
for app in pumpfiction goldenhour shouldiride; do
jq -n --arg k "$GOOGLE_MAPS_KEY" '{googleKey: $k}' > "$app/config.json"
done
fi
The page reads it at startup, and treats a missing file or an empty key as “no Google today”:
const config = fetch('config.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : {}))
.catch(() => ({}));
cache: 'no-store' matters: without it, a browser can hold on to the old file after you change or rotate the key. The site has since moved its key to the server, which is section 8.
3. Restrict the key, twice
In the key’s settings there are two separate restrictions, and you want both:
- Application restrictions → Websites. List the addresses allowed to use it, such as
https://www.dusanjov.com/*. A request from any other site, or from a script that copied the key, gets refused. For local testing, addhttp://localhost:8000/*while you work and remove it after, or use a second key for development. - API restrictions. Tick only the APIs you enabled. If the key leaks anyway, it can’t be used for the expensive ones you never touch.
The website check relies on the Referer header, which a determined person can fake outside a browser. That’s why the last line of defence is a cap on usage, in section 6.
4. Calling the Places API (New)
The new Places API is a POST with a JSON body. The key goes in a header, along with a field mask that lists exactly which fields you want back. The mask isn’t optional: leave it out and the request fails.
const res = await fetch('https://places.googleapis.com/v1/places:searchNearby', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': key,
'X-Goog-FieldMask': 'places.id,places.displayName,places.location,places.fuelOptions',
},
body: JSON.stringify({
includedTypes: ['gas_station'],
maxResultCount: 20,
rankPreference: 'DISTANCE',
locationRestriction: {
circle: { center: { latitude: lat, longitude: lon }, radius: 10000 },
},
}),
});
The field mask also sets the price. Google sorts fields into tiers, and a request is billed at the most expensive field you asked for. Golden Hour only needs a name, a location and a type, so it asks for nothing else and stays on the cheaper tier. Adding rating or photos just because they’re there would move every request up a tier.
A few details I ran into:
- Twenty results is the limit per search, and the radius tops out at 50 km. For a long drive, Pump Fiction splits the route into pieces of about 160 km and runs a
searchTextwithsearchAlongRouteParameters(the route as an encoded polyline) for each piece, at most eight. - Prices come in Google’s money format: whole
unitsas a string plusnanos, billionths.Number(p.units) + p.nanos / 1e9turns it into a normal number. - Place types change. New types get added over time, and an unknown one makes the whole request fail with a 400. Golden Hour retries once with a shorter list of long-standing types.
- Errors are readable. Google returns
error.messagein the body, and showing that to the user beats “something went wrong”. “API key not valid” or “This API is not enabled” tells you exactly what to fix.
5. Calling the Pollen API
Not every Google API looks the same. The Pollen API is a plain GET, with the key in the URL:
const url = 'https://pollen.googleapis.com/v1/forecast:lookup'
+ `?key=${encodeURIComponent(key)}`
+ `&location.latitude=${lat}&location.longitude=${lon}`
+ '&days=1&plantsDescription=false';
const r = await fetch(url);
if (!r.ok) return null; // e.g. a country with no pollen data
const day = (await r.json()).dailyInfo?.[0];
Coverage is limited to certain countries, so a failed lookup isn’t a bug. Should I Ride? falls back to Open-Meteo’s pollen numbers (Europe only), and if neither has data, it simply leaves pollen out.
6. Keep the bill at zero
Google Maps Platform gives each kind of request a free monthly allowance, and the allowance is smaller for the pricier tiers. A personal site rarely gets near it, but a bug that calls the API in a loop can. Three things keep me calm:
- Cache in the browser. Pollen changes once a day, so Should I Ride? keeps a lookup for three hours. Golden Hour keeps its spots for a day per place and distance. Coming back to the page costs nothing. Google’s terms limit how long you may store their data, so keep these caches short.
- Cap the quota. Under APIs & Services → [the API] → Quotas, lower the requests per day to something you’d never reach legitimately. A cap actually stops requests; a budget alert only sends you an email. One catch: on the free trial, I couldn’t edit these quotas until upgrading to a paid account.
- Set a budget alert anyway, under Billing → Budgets & alerts, at a few dollars.
7. Make Google optional
Every app on this site works without the key. That’s not just politeness: it means a revoked key, a used-up quota or a Google outage only removes one feature.
- Pump Fiction without a key lists OpenStreetMap gas stations, just without prices.
- Golden Hour asks Google and OpenStreetMap at the same time. If Google fails, it shows the OpenStreetMap viewpoints and says why.
- Should I Ride? uses Open-Meteo pollen where it can, or skips pollen.
const [google, osm] = await Promise.all([
googleKey ? googleSpots(googleKey, lat, lon, r).catch((e) => ({ error: e.message })) : [],
viewpoints(lat, lon, r).catch(() => []),
]);
The .catch on the Google call is the important part. Without it, one rejected promise takes the whole Promise.all down, and the free data with it.
8. Or keep the key on your server
Everything above accepts that the key is public and limits what it can do. The other way is to never send it to the browser. The page calls an address on your own site, such as /api/stations, and your server adds the key and forwards the request to Google. Even a static site usually sits on a server that can do this, and it doesn’t take a big framework. This site now does it with the Python version below, for all three apps.
It changes how you restrict the key, too. Instead of limiting it to your website, you limit it to your server’s IP address. That’s much stronger, because a request has to actually come from that machine, while a Referer header can be faked by anyone with curl.
The lightest option: nginx
If your site is already served by nginx, it can do the forwarding with no new software. It adds the key and the field mask itself, and it only allows the one endpoint and method the app uses:
# In the http block: at most 30 requests a minute per visitor
limit_req_zone $binary_remote_addr zone=google:1m rate=30r/m;
server {
# ...your existing site...
location = /api/places/searchNearby {
limit_except POST { deny all; }
limit_req zone=google burst=10 nodelay;
proxy_pass https://places.googleapis.com/v1/places:searchNearby;
proxy_ssl_server_name on;
include /etc/nginx/google-key.conf; # proxy_set_header X-Goog-Api-Key "...";
proxy_set_header X-Goog-FieldMask "places.id,places.displayName,places.location,places.fuelOptions";
proxy_set_header Cookie "";
}
}
The key lives in /etc/nginx/google-key.conf, outside the web root and outside git, readable only by root. Setting the field mask in nginx means a visitor can’t ask for pricier fields on your bill. The Pollen API takes its key in the URL, so there a rewrite adds it, and nginx keeps the visitor’s own query (lat and lon) after it:
location = /api/pollen {
limit_except GET { deny all; }
limit_req zone=google burst=10 nodelay;
include /etc/nginx/google-pollen.conf; # rewrite ^ /v1/forecast:lookup?key=... break;
proxy_pass https://pollen.googleapis.com;
proxy_ssl_server_name on;
}
This is quick, but nginx is only a pipe. It can’t check that a latitude is really a latitude, it passes on whatever request body the visitor sends, and it can’t cache one answer for everyone nearby. For that you need a few lines of real code.
The safer option: a small Node or Python service
The point of moving the key to a server is lost if /api/ forwards anything it’s given. Then you’ve built an open door to Google with your key on it, and anyone who finds the address can use your quota from a script. A small service fixes that, because the server decides what gets asked and the visitor only supplies the bits that should vary:
- Fixed endpoints.
/api/stationsdoes one thing. There’s no generic “forward this to Google” route. - The server builds the request. Place types, radius, result count and field mask are set in code. The visitor only sends
latandlon, and they get checked as numbers in range. - A shared cache. Browser caching only helps one visitor. A server cache helps everyone: two people in the same area get one Google call between them.
- A limit per visitor, so one script can’t use up the day’s quota.
- Plain errors. Return a short message of your own, not Google’s raw response.
Here it is in Node, with no packages (Node 18 or newer has fetch built in):
// api.mjs: run with Node 18 or newer, no packages needed.
import http from 'node:http';
const KEY = process.env.GOOGLE_KEY; // from the environment, never from a file in the web root
const CACHE_MS = 30 * 60 * 1000;
const cache = new Map(); // shared by every visitor
const hits = new Map(); // requests per visitor this minute
setInterval(() => hits.clear(), 60 * 1000).unref(); // a new minute, a new count
setInterval(() => cache.clear(), 60 * 60 * 1000).unref(); // so the cache can't grow forever
function number(value, min, max) {
const n = Number(value);
if (value === null || !Number.isFinite(n) || n < min || n > max) throw new Error('bad input');
return n;
}
http.createServer(async (req, res) => {
const send = (code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
const url = new URL(req.url, 'http://localhost');
if (req.method !== 'GET' || url.pathname !== '/api/stations') return send(404, { error: 'Not found' });
// nginx passes the visitor's address in X-Real-IP.
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress;
hits.set(ip, (hits.get(ip) || 0) + 1);
if (hits.get(ip) > 20) return send(429, { error: 'Too many requests' });
let lat, lon;
try {
lat = number(url.searchParams.get('lat'), -90, 90);
lon = number(url.searchParams.get('lon'), -180, 180);
} catch {
return send(400, { error: 'lat and lon, please' });
}
// Round to about 1 km, so people near each other share one cached answer.
const where = `${lat.toFixed(2)},${lon.toFixed(2)}`;
const kept = cache.get(where);
if (kept && Date.now() - kept.at < CACHE_MS) return send(200, kept.data);
try {
const r = await fetch('https://places.googleapis.com/v1/places:searchNearby', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': KEY,
// The server picks the fields, so nobody can ask for pricier ones on your bill.
'X-Goog-FieldMask': 'places.id,places.displayName,places.location,places.fuelOptions',
},
body: JSON.stringify({
includedTypes: ['gas_station'],
maxResultCount: 20,
rankPreference: 'DISTANCE',
locationRestriction: { circle: { center: { latitude: lat, longitude: lon }, radius: 10000 } },
}),
});
if (!r.ok) return send(502, { error: 'Google said no' });
const data = await r.json();
cache.set(where, { at: Date.now(), data });
send(200, data);
} catch {
send(502, { error: 'Google did not answer' });
}
}).listen(8787, '127.0.0.1'); // only nginx can reach it
The same idea in Python, using only the standard library, here for the Pollen API:
# api.py: Python 3, standard library only.
import json, os, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlencode, urlparse, parse_qs
KEY = os.environ['GOOGLE_KEY']
POLLEN = 'https://pollen.googleapis.com/v1/forecast:lookup'
cache = {} # pollen changes once a day, so keep answers for 3 hours
class Api(BaseHTTPRequestHandler):
def send(self, code, body):
data = json.dumps(body).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(data)
def do_GET(self):
url = urlparse(self.path)
if url.path != '/api/pollen':
return self.send(404, {'error': 'Not found'})
q = parse_qs(url.query)
try:
lat, lon = float(q['lat'][0]), float(q['lon'][0])
except (KeyError, ValueError):
return self.send(400, {'error': 'lat and lon, please'})
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
return self.send(400, {'error': 'lat and lon, please'})
where = f'{lat:.2f},{lon:.2f}'
kept = cache.get(where)
if kept and time.time() - kept[0] < 3 * 3600:
return self.send(200, kept[1])
params = urlencode({'key': KEY, 'location.latitude': lat, 'location.longitude': lon,
'days': 1, 'plantsDescription': 'false'})
try:
with urllib.request.urlopen(f'{POLLEN}?{params}', timeout=10) as r:
body = json.load(r)
except Exception:
return self.send(502, {'error': 'Google did not answer'})
cache[where] = (time.time(), body)
self.send(200, body)
ThreadingHTTPServer(('127.0.0.1', 8788), Api).serve_forever()
Both listen on 127.0.0.1 only, so nobody on the internet can reach port 8787 or 8788 directly. The only way in is through nginx, which forwards the public /api/ address to the service, handles HTTPS and adds its own rate limit:
location /api/ {
limit_req zone=google burst=10 nodelay;
proxy_pass http://127.0.0.1:8787; # 8788 for the Python one
proxy_set_header X-Real-IP $remote_addr;
}
The page then calls your own address, with no key anywhere:
const r = await fetch(`/api/stations?lat=${lat}&lon=${lon}`);
const data = r.ok ? await r.json() : null; // no key anywhere in the page
To keep the service running after a reboot or a crash, a systemd unit is enough. The key goes in an environment file only root can read, and DynamicUser runs the service as a throwaway user with no rights to the rest of the system:
# /etc/systemd/system/site-api.service
[Unit]
Description=Small API for dusanjov.com
After=network-online.target
[Service]
ExecStart=/usr/bin/node /opt/site-api/api.mjs
EnvironmentFile=/etc/site-api/google.env # GOOGLE_KEY=..., chmod 600, owned by root
DynamicUser=yes
Restart=always
[Install]
WantedBy=multi-user.target
What’s still exposed
This applies to all three options. The service itself is private, but hiding the key doesn’t make /api/ private. It’s a public address, and anyone can call it the same way your page does. What you’ve gained is control: the key can’t be copied, and every request goes through rules you wrote. A few more things to keep in mind:
- Don’t add CORS headers. Without
Access-Control-Allow-Origin, other websites can’t use your/api/from their visitors’ browsers. Scripts on a server can still call it, which is what the rate limit is for. - Checking
OriginorRefereris a speed bump, not a lock. Fine to add, but don’t rely on it. - Keep the daily quota cap in Google Cloud. It’s still the one limit that holds even if your own code has a bug.
- Keep the key out of logs. The Pollen API carries it in the URL, so don’t log the full URLs your service calls.
- It’s one more thing to run. A service needs updates and can go down. That’s why the fallbacks from section 7 still matter.
My rule of thumb: for a cheap, read-only API, a browser key restricted by website and API, plus a quota cap, is fine. When a request costs real money, when you want one cache for everyone, or when you’d like to sleep better, put a small service in front of it.
9. How bigger services stop abuse
A public /api/ is how most web apps work, and they all face the same question: how do you stop someone from calling it a million times? No single trick does it, so services stack layers, roughly from simplest to heaviest:
- Rate limits per visitor. Almost everyone starts here: a cap like 60 requests a minute per IP address, set in nginx, the app or a CDN.
- Caching. The same question gets the same answer for a while, so a flood of repeat requests costs little at the paid API behind it.
- A bot check. An invisible challenge such as Cloudflare Turnstile (free) or Google reCAPTCHA runs on the page and hands a real browser a token. The API only answers requests that carry a valid one, which stops most simple scripts.
- Short-lived signed tokens. The page gets a token from the server that expires after a few minutes, and the API refuses anything without one. A request copied out of the developer tools stops working soon after.
- A CDN or firewall in front. Services like Cloudflare block known bad bots and strange traffic before it reaches your server.
- Accounts. Sign in first, then each user gets a quota. The strongest option, and the reason most serious APIs need a login, but overkill for free tools on a personal site.
- Monitoring and hard caps. Usage alerts, plus a daily cap that simply stops, so a bad day costs a known, small amount.
For a site like this one, 1, 2 and 7 are enough, and that’s what it uses: nginx and the Python service limit each visitor to 60 requests a minute, the service caches answers for everyone, and it stops after a set number of Google calls a day, with Google’s own quota cap behind that. If abuse ever shows up in the logs, Turnstile is the next step. Until then, the apps stay free and need no sign-up.
Things that will bite you
- “Places API” and “Places API (New)” are two products. Enabling the old one doesn’t make the new URLs work.
- A new API needs enabling twice: in the project’s API library, and in the key’s API restrictions. Forget the second and you get a 403 that looks like a key problem.
- Changes take a few minutes. New keys and new restrictions don’t apply instantly. Wait five minutes before you start debugging.
- Test on the real address. With a website restriction, the key refuses to work from
file://pages and from any address you didn’t list, including preview copies on another domain. - An IP restriction needs both addresses. If your server has IPv6, it usually talks to Google over IPv6, so a key that only allows the IPv4 address fails with a 403. Add both;
curl -4 ifconfig.meandcurl -6 ifconfig.meshow them. - Rotating a key is cheap, so do it if you ever paste it somewhere public. Create a new one, update the repository variable, redeploy, then delete the old one.
Wrap-up
Start on free, keyless sources and add Google only where nothing else has the data. Keep the key out of git, restrict it by website and by API, ask for as few fields as you can, cache what you can, and cap the daily quota. Then make sure the page still works the day the key stops working. And when the key is worth protecting properly, keep it on the server, behind nginx or a small service that decides exactly what gets asked, with a rate limit, a shared cache and a daily cap in front of it.