· 7 min read
Working Safely with Third-Party APIs in a Serverless Web App
When a web app has no server of its own, the browser talks to third-party services directly. This architecture is simple and cheap, but it leaves the whole security job to the code running in the browser: choosing the services, validating the data that comes back and limiting where the browser may connect. In this post I explain how I handled those three jobs in my weather app, Weather App, and what this architecture costs.
The architecture: a browser and three services
Weather App is plain HTML, CSS and JavaScript, with no build step and no npm dependencies. It needs three things: weather forecasts, city search, and the name of the user's location when they share it. It uses three services for them:
| Need | Service |
|---|---|
| Current weather, 24-hour and 5-day forecast, world city search | Open-Meteo |
| Search for provinces, districts and neighborhoods in Turkey | Photon (komoot, OpenStreetMap data) |
| Place name from coordinates | BigDataCloud |
When a user types "Ankara", the flow is:
- The browser sends the search to Open-Meteo and Photon at the same time.
- The results are validated, merged and listed.
- When the user picks a city, the browser asks Open-Meteo for the forecast.
- The response is validated, turned into a plain object and written to the screen.
There is no server of mine in between. The only server-side code is a small page function whose job is to give every city address, such as /istanbul or /londra, its own title and description for search engines. It never touches weather data and makes no outside requests.
Decision 1: pick services that work from the browser
Without a server, the code that ships to the browser must not contain anything that needs to stay secret. Every value in browser code can be seen by anyone who opens the page source or the network tab in the developer tools. Putting a value in a .env file doesn't change that: the build tool still writes it into the code that goes to the browser.
So the first criterion for choosing services was that they can be used directly from the browser without any secret. All three are open services that allow calls from the browser without sign-up or a secret value:
// Open-Meteo istekleri. Anahtar gerekmez; yanıtlar weather.js ve places.js ile doğrulanır.
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const GEOCODING_URL = 'https://geocoding-api.open-meteo.com/v1/search';
const PHOTON_URL = 'https://photon.komoot.io/api/';
const REVERSE_URL = 'https://api.bigdatacloud.net/data/reverse-geocode-client';
(The comment says: "No key needed; responses are validated in weather.js and places.js.")
If a service the app needed required a secret, this architecture wouldn't fit. Requests would have to go through a server, with rate limiting and input validation on that server. Because Weather App's needs could be met with open services, I chose the serverless architecture.
Decision 2: trust no outside data
The browser receives data from three outside services, and that data goes straight to the screen. If one of them returns a broken, incomplete or unexpected response, the app must not crash or print something strange.
So no response is used directly. It is first turned into a plain object, and every field is checked. Wherever a number is expected, this small helper is used:
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
The current weather response is read like this:
export function parseForecast(raw) {
const current = raw?.current;
if (typeof current?.time !== 'string' || num(current.temperature_2m) === null) return null;
return {
current: {
time: current.time,
temp: current.temperature_2m,
feels: num(current.apparent_temperature),
humidity: num(current.relative_humidity_2m),
wind: num(current.wind_speed_10m),
code: num(current.weather_code),
isDay: current.is_day !== 0,
},
// ...
};
}
The rules are simple:
- If the temperature is missing, the response isn't used and a clear error appears.
- If humidity or wind is missing, that field shows "—".
- A broken hourly row is skipped, and unknown fields are never copied.
- Even the time zone is checked: a value like
<script>is rejected, and a test covers that case.
Search results are cleaned the same way. A result only makes the list if it has a name, a valid latitude (±90) and a valid longitude (±180). Text is capped at 100 characters and only known fields are kept. Saved cities go through the same check when they are read back from the browser's local storage, because local storage can be edited by hand.
Writing to the screen needs care too. The app has no innerHTML at all: text is set with textContent, and the weather icons and the chart are drawn by creating SVG elements one by one. Even if a service returned HTML instead of a city name, it would not run; it would show up as plain text.
Decision 3: fence the browser with a Content Security Policy
Without a server, security rules have to be given to the browser through HTTP headers. The most important one is the Content Security Policy (CSP). In Weather App, the addresses the page may connect to are listed one by one:
connect-src 'self'
https://api.open-meteo.com
https://geocoding-api.open-meteo.com
https://photon.komoot.io
https://api.bigdatacloud.net
...
The page cannot send requests anywhere that isn't on the list. script-src only allows the site's own files and one analytics script; there are no inline scripts and no eval. Location access is limited by a header too: geolocation=(self) lets only this site ask for the location, not content embedded in the page.
The local development server sends the exact same headers, so a request the CSP would block shows up during development rather than in production.
Location: only as much as needed
When "Use my location" is pressed, the browser asks for permission. If it's granted, the coordinates go to two places: Open-Meteo for the forecast and BigDataCloud for the place name. They are sent with four decimal places, which is about 11 meters of precision and more than enough for weather. The location is never stored. If permission is denied, a short notice appears and search keeps working.
The cost of this architecture
This architecture has a cost too:
- Dependency: The app depends on three free services. If one shuts down or changes its terms, it has to be replaced.
- Usage limits: Free services have usage limits and terms; staying within them is my responsibility.
- Control: With a server in between, I could add caching or switch between services invisibly. Now those things have to happen in the browser.
I did two things to soften this. Every request has an 8-second timeout, so the page never waits forever for an answer:
async function getJson(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
City search also goes to two sources at once. If one fails, the other's results are still shown; if both fail, the message "Search failed. Check your connection and try again." appears.
When is a server needed?
A serverless architecture doesn't fit every app. Requests should go through a server when:
- The service has to be called with a secret value.
- The service is paid; then the requests need to be under the app owner's control.
- There is user-specific data, payments or account actions.
Weather App had none of these, so an architecture where the browser talks to the services directly was enough, and its security was built around that architecture.
Summary
- In a serverless app, everything in the code that ships to the browser is public; the services were chosen accordingly, from ones that work from the browser without any secret.
- Every response from an outside service is validated, and missing or broken fields never reach the screen.
- The screen is only written with
textContent; the app has noinnerHTML. - The CSP limits where the browser can connect, and location access is open only to this site.
- Timeouts and a second search source reduce the impact of depending on outside services.
Weather App is live at weather.miracdeprem.com. The source code is on GitHub: github.com/MrcDprm/weather-app.