How to save JSON from a frontend without exposing an API key
You want a web page to save some JSON. The service you’d save it to wants an API key. If the key goes in the page, anyone can copy it. Here’s what to do instead.
Written by jstash, which makes some of the options compared here. Updated 4 October 2026.
Summary
- Anything in browser code is public, including keys your build tool copies in from
.env. Hiding or scrambling a key doesn’t help. - The real question is who may write what. Something the visitor can’t change has to check that: your server, a serverless function, or a service that checks each person’s login.
- Each signed-in person saving their own data? Send their login token, not a key. Use Supabase or Firebase with per-user rules, or jstash Apps, which has that rule built in.
- Anonymous visitors, or an API that only takes a secret key? Keep the key in a small serverless function that checks and limits each request.
- Only you write it and everyone reads it? Write from a script or CI that holds the key, and have the page read the result with a plain
fetch.
Why a key in your page leaks
A browser has to download your JavaScript to run it, so everyone who visits your site gets a copy. Any key inside it can be read in the browser’s developer tools. Every request your page sends shows up there too, headers and all.
Environment variables don’t change that. Build tools copy them into the files they ship. Vite says variables starting with VITE_ “should not contain sensitive information such as API keys”. Next.js inlines NEXT_PUBLIC_ variables “into any JavaScript sent to the browser”. Create React App puts it plainly: “Environment variables are embedded into the build, meaning anyone can view them by inspecting your app’s files.”
People do find them. In October 2025, Escape scanned 5,600 apps made with AI app builders and found more than 400 exposed secrets. Supabase service keys were “often trivially retrievable from frontend bundles”. In April 2026, a developer reported about €54,000 of Gemini charges in 13 hours. Their Firebase browser key had no API restrictions, and automated traffic used it for Gemini requests.
Limiting a key to your website’s address doesn’t make it private either. Browsers set the Origin header themselves, so a web page can’t change it. A script running outside a browser can send whatever it likes.
Public keys and secret keys
Some keys are meant to be in your page. Firebase says its API keys “are OK to include in code or checked-in config files”, and Supabase says its publishable key is “safe to expose online”. These keys only say which project to talk to. What protects the data is the rules you write for it: Firebase Security Rules, or Row Level Security in Supabase.
Without the rules, the same public key opens everything. In February 2026, Wiz reported a social network whose Supabase key was in its JavaScript without Row Level Security policies. Anyone could read and write the whole database, including 1.5 million API tokens and 35,000 email addresses.
Secret keys are different. Supabase’s secret key “bypasses every Row Level Security policy you have”, and its docs say: “Never put one in a browser, a shipped application, or source control.” The same goes for keys to paid APIs, like AI models, email and payments, and for a jstash API key. Even Firebase makes an exception for Gemini: that key “must be protected from public exposure.”
Option 1: a serverless function
Many static hosts can also run small functions on their servers, like Netlify Functions or Cloudflare Workers. Keep the key in the host’s environment variables or secrets. Your page calls your function, and only the function calls the API.
// netlify/functions/save.mjs runs on Netlify’s servers, not in the browser export default async (req) => { const user = await verifyUser(req); // check the login token: see below if (!user) return new Response('Sign in first', { status: 401 }); const res = await fetch(`https://api.example.com/data/${user.id}`, { method: 'PUT', headers: { Authorization: `Bearer ${process.env.EXAMPLE_API_KEY}` }, body: await req.text(), }); return new Response(await res.text(), { status: res.status }); }; export const config = { path: '/api/save' };
The catch: your function is now a public API. Anyone can call it, not just your page. If it passes every request straight through, the key is hidden but anyone can still use it. So the function has to do the checking:
- Who is calling. Check the person’s login token with your login provider’s server library, for example Firebase’s
verifyIdToken().verifyUser()above stands for that. - What they may touch. Build the path from the checked user ID, never from the request, so nobody can write someone else’s data.
- How much. Limit the size of each request and how often each person can call. Endpoints anyone can use without signing in, like a contact form, need this most.
Use it when you call an API that only takes a secret key, or when anonymous visitors send data. It’s the most flexible option, and the checks are yours to write and maintain.
Option 2: your own backend
If you already run a server, it’s the same idea. Add an endpoint, keep the key and the database password in the server’s environment, and check the person’s session before you write. You own the database, so you can query it and look at your users’ data. You also run, secure and pay for the server.
Option 3: a database with per-user rules
Supabase and Firebase let your page talk to the database directly, with the public key and the person’s login. You write rules that keep each person to their own data. In Supabase, that’s Row Level Security:
alter table notes enable row level security; create policy "Read own notes" on notes for select to authenticated using ((select auth.uid()) = user_id); create policy "Add own notes" on notes for insert to authenticated with check ((select auth.uid()) = user_id); create policy "Change own notes" on notes for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
Add a delete policy too if people can delete. Firebase’s version is a Security Rule, and its docs call these rules “the only safeguard blocking access for malicious users.”
The rules are code, and a missing one doesn’t show up as a bug: the app still works. On 1 October 2026, UpGuard reported 16,326 Supabase databases with tables anyone could read, from about 300,000 sites that appear to use Supabase. It notes that tables created through the API, which is how coding agents create them, don’t have Row Level Security turned on by default.
Use it when people share data, you need queries or relationships between records, or you want live updates. That’s what these databases are for, and jstash doesn’t do any of it. Write the rules before the data arrives, and test them signed in as two different people.
Option 4: jstash Apps, with the user’s login token
jstash Apps gives each signed-in person their own private JSON documents, with no key in the page and no rules to write. Your page sends the login token it already has from Clerk, Supabase Auth, Firebase Auth or Sign In With Google. jstash checks the token’s signature with your login provider’s published keys, then reads or writes only that person’s documents.
const JSTASH = 'https://api.jstash.app/v1/apps/app_…/me'; // from your jstash dashboard // The signed-in person’s login token, here from Clerk. // Supabase: (await supabase.auth.getSession()).data.session.access_token // Firebase: auth.currentUser.getIdToken() const token = () => Clerk.session.getToken(); async function save(name, value) { const res = await fetch(`${JSTASH}/${name}`, { method: 'PUT', headers: { Authorization: `Bearer ${await token()}` }, body: JSON.stringify(value), }); if (!res.ok) throw new Error((await res.json()).error.message); } await save('settings', { theme: 'dark' });
- Nothing secret in the page. The login token belongs to the person already holding it, and it expires. jstash uses it to check who’s signed in and doesn’t store or log it. A jstash API key sent from a page is refused.
- One rule, built in. Documents are filed under your app and the person’s ID, so a request only reaches the signed-in person’s own documents. There’s no rules language to get wrong.
- Only your websites. Each app accepts browser requests from up to 5 website addresses you list, plus localhost while you develop.
- Free to try: 1 app with up to 100 users, documents up to 100 KB, and 100 saves per person a day. See all limits.
Use it when each person’s data is theirs alone: settings, progress, drafts, saved items. Don’t use it for data people share, for search or queries, for live sync, or if you need to look at your users’ data, because jstash gives you no way to read it. It keeps no backups either. Setup for each login is in the Apps guide.
Option 5: data only you write
Sometimes the page only reads, and you’re the one who writes: opening hours, a price list, a status message, settings for a widget. Then the key never needs to be near a browser. Write from your own machine, a script or CI, where the key stays, and have the page read the result.
A JSON file in your repo does this, if a redeploy for each change is fine. If it isn’t, a jstash bin is a JSON document at its own URL that you can change without redeploying. Create it once in the dashboard, then:
# From your machine or CI. The key stays here.
curl -X PUT https://api.jstash.app/v1/bins/<bin-id> \
-H "Authorization: Bearer $JSTASH_API_KEY" \
--data-binary @hours.json
// In your page. No key needed.
const hours = await fetch('https://j.jstash.app/<bin-id>').then((r) => r.json());
- In GitHub Actions, keep the key in a repository secret.
- Browser JavaScript on other websites can’t call the bins API. That’s on purpose: it keeps API keys and edit tokens out of web pages. Reading works from any website.
- Anyone with the URL can read a public bin, so don’t put anything private in one.
- After a change, browsers can see the old copy for up to 5 minutes.
Which one to use
data only they seeTheir login token: jstash Apps, or Supabase or Firebase with per-user rules
data they shareSupabase or Firebase with rules, or your own backend
forms, votes, sign-upsA serverless function with size and rate limits, or your host’s form handling
and everyone reads itA script or CI that holds the key, then a JSON file in your repo or a jstash bin
AI, email, paymentsA serverless function or your backend. Never the browser
Already signing people in with Supabase or Firebase? Their database with a per-user rule is one less service to add.
Before you ship
- Search your build output (
dist/orbuild/) for the first few characters of each secret key. If it’s there, it’s public. - If a secret key was ever in a page or a public repo, replace it. Deleting the line doesn’t take back copies people already have. GitHub’s advice is to revoke or rotate the secret as the first step.
- Test as someone else. Sign in as a second person and try to read or change the first person’s data. Your rules or checks should refuse.
More guides
- How to save user data in a static website
- How to add per-user cloud storage to a Clerk app
- localStorage vs cloud storage for small web apps
- How to add a database to a static website — and when you don’t need one