Some keys can live in the browser and some must never leave the server, and one question sorts them: does this key identify the app, or does it act with your permissions? API keys exposed on frontend code are expected when they are the first kind. In my June and July 2026 audits, 6 of the 21 third-party apps shipped a real secret.
What these controls are, together: the API keys exposed on frontend code that matter, and the ones that do not
An API key belongs in the browser only when it identifies the app to a vendor and is designed to be public: a publishable key, a Firebase config, a restricted Maps key. A key that acts with your permissions, such as a secret key, a service role key or an AI provider key, stays on the server.
Those 21 were third-party apps I audited in June and July 2026, a selected set and not a random sample, so the 6 is a count for that group rather than a rate for AI-built apps in general. The same audits ranked the Secrets & Credentials pillar the best of the 12 pillars, with an average of 84.4 out of 100, scored on 21 of the 21 apps. The general habits for storing, sharing and rotating any key are in secrets management best practices for an AI-built app; this page covers one narrower thing, which keys may reach a visitor’s browser.
Hiding a key in frontend code does not work. Every visitor downloads the same bundle and view-source is one keystroke away, so an API key visible in browser source is public the moment the page loads. How to hide API keys in frontend code is therefore the wrong thing to ask; what decides the fix is which pile each key belongs in, and that is the rule I use for the table below.
| Key | Vendor | Identifies the app or acts for you | Browser or server | What limits a copied browser key |
|---|---|---|---|---|
Publishable key (sb_publishable_...) or legacy anon key | Supabase | Identifies the app | Browser | Row Level Security: “it only reaches what Row Level Security allows” |
Secret key (sb_secret_...) or legacy service_role key | Supabase | Acts for you | Server only | Nothing; see the service role section below |
| Web config API key | Firebase | Identifies the project and app | Browser | Covered in the Firebase article linked below |
Publishable key (pk_test_, pk_live_) | Stripe | Identifies your account | Browser | It can identify your account but “can’t perform sensitive operations such as creating charges or reading account data” |
Secret (sk_) and restricted (rk_) keys | Stripe | Acts for you | Server only | Nothing; Stripe marks both “No” for safe to expose |
| Maps JavaScript API key | Google Maps Platform | Identifies the app | Browser, restricted | A Websites application restriction plus API restrictions |
| Token with public scopes | Mapbox | Identifies the app | Browser, restricted | URL restrictions, on a token you create |
| Token with secret scopes | Mapbox | Acts for you | Server only | Nothing; Mapbox says to make secret-scope requests on a server |
| AI provider key (OpenAI and others) | AI provider | Acts for you | Server only | Nothing; OpenAI’s production guide says to avoid “exposing the API keys in your code” |
| Email provider key (Resend and others) | Email provider | Acts for you | Server only | Nothing; Resend says its keys “must be kept confidential” |
Database URL (postgres://, postgresql://) | PostgreSQL | Acts for you | Server only | Nothing; the URL can carry the user and password |
The middle columns are my rule, and so are the server piles for AI and email keys; the vendor words in the last column are theirs. The Firebase line is the one I do not explain here: is Firebase secure: what Google protects and what you configure covers why that key can sit in client-side code and what does the protecting instead.
A publishable API key is the kind a vendor builds to be copied: Stripe says you “can include it in front-end code or applications you distribute”, and Stripe’s API keys docs add that “Only publishable keys are safe to expose outside your application’s backend”, restricted keys included in what you must protect. Supabase’s API keys guide calls its publishable key “Safe to expose online”. A service role key is the opposite kind: Supabase’s server key that runs as the service_role Postgres role and so bypasses Row Level Security. Supabase lists its legacy service_role key as the “Legacy version of secret keys”, and the section on storing service role keys below quotes what one can do.
The Production Hardening Sprint covers both piles as two deliverables: 2.1 removes all private secrets from frontend code and downloadable bundles, relocating their use to server-side code, and 2.3 verifies service-role and administrative keys are used exclusively in protected server environments.
What goes wrong without them
Each wrong-pile key fails in its own way, and each has a section below.
| What shipped | Who can use it | What it costs you |
|---|---|---|
| An AI or email provider key in the JavaScript | Anyone who opens the bundle | Usage billed to your account, mail sent in your name |
| A service role or secret key in the client | Anyone who opens the bundle | The full access quoted in the service role section |
| A health or debug route that returns the environment | Anyone who requests the path | The database URL and every key the route prints |
A private key in the bundle
An AI provider key or an email key in the JavaScript is an exposed private credential, and exposed private credentials can let others access services using your permissions. Because those services are metered, the cap and the alert that limit the damage are in how to cap monthly usage on a metered API.
Take a builder app whose built JavaScript holds two key-shaped strings: a Maps browser key the page needs, and an AI provider key the builder read from a variable with the framework’s public prefix, which the build inlines into the browser bundle. A scan flags both; the owner restricts the Maps key and leaves the AI key alone because it came from an environment file, so anyone who copies it from the bundle can use the AI service with the owner’s permissions. The lesson I take from it: where a key came from does not decide its pile, what it can do does.
In the same audits, three apps had a real secret permanently in git history, a live AI-provider key among them. That is a count from the same hand-picked group of 21, not a rate. History is its own search, covered in secret scanning for a small app. The class-by-class picture of what sits in the open is at exposed API keys in a vibe coded app.
A service role key in the client
A service role key exposed in client code hands whoever copies it the full access quoted in the section on storing service role keys below. A builder app that “fixed” a permissions error by switching the browser client to the service key has made exactly that trade: in my reading, the error went away because the policies stopped applying to that client.
Why the key belongs on the server, from the database side, is covered under keep the service_role key server-side, always. On Lovable, the Lovable check for a service_role key that shipped covers the same leak for apps built on it, and what someone can do with each Supabase credential sets out the reach of every Supabase key.
A health check or debug route that prints the config
A health check leaking database credentials is the same failure through a route instead of a bundle. A /health or /api/debug handler, or an error page, that returns the environment hands the database URL and every key in it to anyone who requests the path. A Postgres connection URI can hold the user and password in the URL itself, so one leaked line can be a login. The fix is a health route that returns a status and nothing else; check 4 in the verify list below tests it.
How to set them up
Frontend secrets are handled in two moves: every key that acts with your permissions goes behind a server route the browser calls instead, and every key that stays in the browser gets the vendor’s restrictions turned on, so a copied key works only where and for what you allowed.
The restriction settings below come from the vendors’ own documentation, and the five steps are my own order of work; neither is a report of a migration of your app or anyone else’s.
How to move an API key server side
Moving an API key server side takes 5 steps: a server route holds the key and makes the vendor call, the browser calls that route, the route requires the user’s session and a limit, the public copy and any public-prefixed variable are deleted, and the old key is rotated.
- 01 Create a server route, server action or edge function that reads the key from server-side environment variables and makes the vendor call.
- 02 Change the browser code to call your route instead of the vendor.
- 03 Require the signed-in user's session on the route and accept only a narrow input, such as the id of an object the user owns, never an open prompt to a paid model; add a per-user limit.
- 04 Delete the public copy of the key and rename any variable that carried it under a public prefix.
- 05 Rotate the key, because every visitor who loaded the old bundle already has the old value.
That route is how a key the browser must never see stays hidden: the browser never receives it at all. Step 5 is my reading of why deleting alone is not enough. How to swap the key without breaking your environments is in how to rotate API keys safely.
The public prefix is the usual way a secret gets into the bundle. A variable prefixed with NEXT_PUBLIC_ “will be inlined into any JavaScript sent to the browser”, per Next.js’s environment variables guide. In Vite, “Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling”, per Vite’s env and mode guide. A secret under either prefix is public once the build runs. Which prefix each framework and builder uses is covered under framework prefixes decide what reaches the browser; the fix here is the rename plus the move, because a .gitignore change does nothing for a value already compiled into the bundle.
Mobile, map and browser keys: restrict the key, do not try to hide it
A Maps or Mapbox browser key has to sit in the page to work, so its protection is a restriction rather than concealment: an application restriction and an API restriction on a Google key, a URL restriction on a Mapbox public token you create, and a separate key for each app.
That is what JavaScript API key security comes down to for this kind of key: limiting what a copy can do, since Mapbox itself says any public token you include in a webpage “will be visible to anyone who makes an effort to look for it”.
Google Maps Platform’s security guidance puts Google Maps API key security in one line: “Best practice is to always restrict your API keys with one type of application restrictions and one or more API restrictions.” For a website using the Maps JavaScript API, the application restriction is Websites, a list of referrer sites, and the API restrictions name only the Maps APIs the page calls. Google also says to use separate API keys for each app, because “If one API key is compromised, you can delete or rotate the impacted key without needing to update your other API keys.”
The same console handles Android API key security: a key inside an Android app can be restricted to the app’s package name and SHA-1 signing certificate fingerprint, and a key restricted to Android apps cannot be used with iOS, web services or JavaScript APIs. Google Cloud’s guide to API key restrictions adds that “You can set only one type of client restrictions per API key”, so a website and an Android app need separate keys, and restrictions can be added through the API Keys API’s CreateKey method or after the key is created with UpdateKey.
Mapbox API key security depends on the token’s scopes. Mapbox’s guide to using tokens securely says a browser app “should only use a token with public scopes” and that requests needing secret scopes belong on a server. Its URL restrictions make a token “only work for requests that originate from the URLs you specify”, but they need a token you create, because “The default public token in your account does not allow for additional security features such as scope management and URL restrictions”, and they need a recent enough Mapbox GL JS (the version is in the table below).
| Vendor | Restriction | Where it is set |
|---|---|---|
| Google Maps Platform | Application restriction: Websites, Android apps (package name and SHA-1 signing certificate fingerprint), iOS apps or IP addresses, one type per key | Google Cloud console, Google Maps Platform Credentials page, Edit API key page, under Key restrictions |
| Google Maps Platform | API restrictions: only the APIs the key may call | The same Edit API key page, under API restrictions |
| Google Cloud (any API key) | The same client and API restrictions | API Keys API, CreateKey or UpdateKey |
| Mapbox | URL restrictions on a public token you create, not the default public token; Mapbox GL JS 0.53.1 and greater | Not stated in Mapbox’s security guide |
Where the key travels matters too. Google Cloud’s API key best practices treat a key in the URL as a security risk: providing it “as a query parameter includes your API key in the URL, exposing your key to theft through URL scans”, and the page says to “Use the x-goog-api-key HTTP header or a client library instead.” A billing alert is the backstop for any browser key, and it is on the metered-API page linked above.
Where to store service role keys safely
A service role key is stored in the server runtime’s environment and nowhere else: not under a public prefix, not in a file the client build reads, not in a mobile app. Supabase says its secret and service_role keys “provide full access to your project’s data, bypassing Row Level Security”.
In practice that means the host’s environment settings, a secrets manager, or the CI’s protected secrets. Supabase’s list of places a secret key must never appear includes “A browser, even on localhost.” Supabase’s current secret key runs as service_role, which carries the BYPASSRLS attribute, and the legacy service_role JWT is the older form of the same access.
The difference between the two is in the checks Supabase added. Supabase says secret keys “improve on the old JWT-based service_role key” with checks that prevent misuse, one being that “A secret key doesn’t work in a browser”: Supabase matches on the User-Agent header and returns HTTP 401 Unauthorized. In my reading, that stops a careless browser call, not someone who copied the key and sends it from a script of their own. Supabase is also deprecating the anon and service_role keys by the end of 2026.
My working rule: every code path that uses the admin key should be a server file, and the list of those files should be short enough to read in one sitting. What a key is, and what it means when one stops working, is in what an API key is and is not.
How to verify each one
Frontend secrets are verified against the built output and the live traffic, not the source alone: search the build directory for key prefixes, view-source the deployed page, read every request’s headers through the five main flows, and request the health and debug routes signed out. Keep the build id and date with the output.
To check a bundle for exposed secrets, search only what the browser downloads. Server output can hold server-only values by design, so a search over it fails a correct build (my reading). A clean result covers that build on that date; the next build needs the same search.
- 01 Build the app and search the client output, the files the browser downloads, with the command below. Evidence: the output, with the build id and date.
- 02 Open view-source on the deployed page and search it for the same strings. Evidence: a saved copy of the page, with the date.
- 03 In Chrome DevTools, open the Network panel and use the app through its five main flows: sign-up, sign-in, the paid action, the upload and the password reset. Select each request under the Name column, open the Headers tab, and read the Authorization header, any
apikeyheader and the query string for a key that is not a publishable one. Evidence: a HAR export. - 04 Signed out, request
/health,/api/health,/api/debugand/.envon your own app and confirm each returns a status, a 404 or the app's normal page, with no configuration values. Evidence: the four responses. - 05 List every environment variable with a public prefix and confirm none holds a secret. Evidence: the list.
- 06 Search the server code for the admin key's variable name and confirm every hit is a server file. Evidence: the hit list.
# Vite's default output folder is dist; on Next.js, point BUILD_DIR at .next/static
BUILD_DIR=dist
grep -rnoE 'sk_(live|test|org)_|rk_(live|test)_|sb_secret_' "$BUILD_DIR"
grep -rnoE 'postgres(ql)?://' "$BUILD_DIR"
grep -rnoE 'eyJ[A-Za-z0-9_.-]+' "$BUILD_DIR"
grep -rnF -f ~/server-key-starts.txt "$BUILD_DIR"
The prefixes come from the vendors: Stripe’s secret and restricted keys start with sk_live_, sk_test_, rk_live_ and rk_test_, its organization keys with sk_org_, and Supabase’s secret keys with sb_secret_. PostgreSQL’s connection URI scheme can be either postgresql:// or postgres://. A hit on sk_live_ followed by the rest of a key means a live Stripe secret key is in the JavaScript bundle, and step 5 of the move applies at once.
The last line reads server-key-starts.txt, a file you make outside the project with the first dozen or so characters of each server-only key the app holds, one per line. It catches providers whose docs state no prefix, as with OpenAI’s production guide and Resend’s API key page, and it avoids a bare short prefix such as sk-, which also matches ordinary words like task- (my reading). Delete the file when you are done.
When you check built assets for admin keys on a Supabase app, the eyJ line matters most. Supabase says its publishable and secret keys “are short strings, not JWTs” and that a tutorial telling you to copy a long key that begins with eyJ “was written for the legacy keys”; its table lists both the legacy anon key and the legacy service_role key as JWTs, so a hit alone does not prove a leak. Decode the middle part of each hit with a decoder that runs on your machine and read which role it names: anon is expected, service_role is the leak (my reading).
For check 3, Chrome’s default export is “sanitized” and “excludes sensitive information such as Cookie, Set-Cookie, and Authorization headers”, so a HAR that shows a key sent in the Authorization header needs Settings > Preferences > Network > “Allow to generate HAR with sensitive data” turned on first. That file holds live session tokens, so keep it private and delete it once the check is recorded.
On the sprint, we verify deliverable 2.1 this way: scan source and built assets and inspect browser traffic for private credentials. For 2.3 we check runtime configuration, built assets, and each code path using administrative credentials.
Scanning the repository and its history is a separate job, on the secret scanning page linked above. The general securing guide carries a short version of the build-output search and the move, under remove an exposed API key without creating a second leak. The platform defaults that put keys in the wrong place are in what are insecure defaults on managed platforms, and the vibe coding security checklist carries browser secrets as one of its checks.
Where the sprint does this
In the Production Hardening Sprint, frontend secret removal and server-only administrative keys are deliverables 2.1 and 2.3, checked the way the verify section above describes. A key found in git history falls under deliverable 2.2, where we scan Git history for committed secrets and rotate every exposed credential found. The results go into the production readiness report (13.1), which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Hosting, paid tools and API usage are paid through your accounts, and we explain any required costs before enabling them. Each of those items has its own line in the sprint’s published deliverable list.
Common questions about keys in browser code
What can someone do with your API key?
Someone with your API key can do whatever that key allows. With a key that identifies the app, they can use it only within the restrictions you set on it; with a key that acts with your permissions, they can call the vendor as if they were your app, and on a metered service that runs up your bill. The key table near the top of this page sorts the common keys into those two piles.
Is it okay to expose Google Maps API key?
A Maps JavaScript API key has to be in the page for the map to load, so its exposure is expected; what makes it acceptable is restriction. Google’s guidance is to restrict every key with one type of application restriction, Websites for a site using the Maps JavaScript API, plus API restrictions for the Maps APIs you use. An unrestricted key is the risk: Google says you are financially responsible for charges caused by abuse of unrestricted API keys.
What happens if an API key is exposed?
What happens depends on the pile. A publishable key is expected in the browser, so nothing changes as long as its restrictions or Row Level Security hold. An exposed secret key has to be replaced and the old value revoked, because everyone who loaded the bundle has a copy; the rotation steps and the first hour after a leak each have their own page in this series, on rotating API keys safely and on what to do when an API key leaked.
Are API keys supposed to be private?
The API keys that act with your permissions are supposed to be private and stay on a server: secret keys, service role keys, AI and email provider keys, database URLs. The ones that identify your app, such as a Stripe or Supabase publishable key or a restricted Maps key, are designed to be public, and what protects them is the vendor’s restriction or, for Supabase, Row Level Security.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase