Published October 2026

Web and mobile apps often need to call a third-party API such as Stripe, OpenAI, an email provider, or a maps service. Those calls need a credential, and the easiest place to put it is an environment variable in the front-end project. Frameworks like Vite, Next.js, and Expo do load variables from .env, but any variable the client code can read is copied into the JavaScript that gets shipped to users. Anyone who loads the site or installs the app can read that JavaScript, so the key is no longer secret.

This article explains why that happens, which vendor keys are meant to be public and which are not, and how to keep the private ones on a server that the client calls instead. It covers web apps (Vite, Next.js, React, SolidJS) and JavaScript mobile apps (Expo and React Native). Native iOS and Android apps follow the same rule and need a server for the same reason.

The major vendors and security groups say the same thing. Google Cloud's API key best practices say not to include API keys in client code or commit them to repositories, and to have the client pass requests to a server that adds the credential and makes the call. Stripe's API keys documentation says only publishable keys are safe outside your backend, while secret and restricted keys are not. OWASP's Secrets Management Cheat Sheet lists hardcoded secrets in source and client-reachable config as a core failure mode. For webhook signing secrets specifically, see webhook signatures, idempotency, and retries.

Why the browser and the phone cannot keep a secret

A web client downloads JavaScript. Anyone can open developer tools, view source, or download the static chunks. A React Native or Expo app embeds a JavaScript bundle in the binary, and anyone who can get the build can unpack it and read the strings.

React Native's security documentation says anything in your client-side code should be considered public and readable to any individual who can run the application. Expo's environment variables guide warns against storing sensitive info, such as private keys, in EXPO_PUBLIC_ variables, because those values are visible in plain text in the compiled application.

Minification and private repositories do not change this. Minification renames variables; it does not encrypt your Stripe secret. A private repo only helps until the first public deploy, fork, screenshot, CI log, or compromised laptop.

The env prefix

Frameworks try to prevent accidental leaks by requiring a special prefix before a value can reach client code. It is easy to read that as “env vars are safe.” It means the opposite: the prefix marks a value that will be baked into the client.

Vite

Vite's env and modes guide states that variables prefixed with VITE_ are exposed in client-side source after bundling, and that they should not contain sensitive information such as API keys because the values are bundled into source at build time. For production secrets, Vite recommends a backend server or serverless/edge functions.

# Client-readable after build (by design)
VITE_API_BASE_URL=https://api.example.com

# Server-only if you never prefix it and never import it from client modules
STRIPE_SECRET_KEY=sk_live_…

Next.js

Next.js environment variable docs state that by default env vars are available only on the server. To expose a value to the browser, you prefix it with NEXT_PUBLIC_, and Next.js inlines it into the JavaScript bundle at build time.

# Inlined into the browser bundle
NEXT_PUBLIC_ANALYTICS_ID=…

# Stays server-side when used only in Server Components, route handlers, or other Node code
DATABASE_URL=…
OPENAI_API_KEY=…

Expo

Expo loads EXPO_PUBLIC_ variables from .env files and replaces references in your JavaScript with the literal values in the app bundle. Its docs warn not to store private keys in those variables.

# Visible in the compiled app
EXPO_PUBLIC_API_URL=https://api.example.com

# Never put sk_ / service-role / SMTP / signing secrets here

A quick way to check an existing repo is to search the client tree for VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, sk_live, sk_test, rk_live, service_role, sb_secret_, BEGIN PRIVATE KEY, and SMTP credentials. A match on a real secret means rotating the key comes before anything else.

Public keys versus secret keys

Some vendor keys are designed to ship in clients. That does not make every key public.

KindExampleClient OK?Still required
Publishable / client keyStripe publishable key (pk_…)Yes, per StripeKeep secret/restricted keys on the server (Stripe keys)
Maps / browser keyGoogle Maps Platform browser keyOften yes, with limitsApply HTTP referrer or app restrictions (Maps security guidance)
Supabase publishable / anonsb_publishable_… or legacy anonYes, with Row Level SecurityNever put secret / service_role in the client (Supabase secure data)
Secret / admin / service roleStripe sk_… / rk_…, OpenAI secret, Supabase secret / service_role, SMTP password, webhook signing secretNoServer only; rotate if exposed

Stripe's secret key best practices add operational detail: never put secret API keys in source code, prefer a secrets vault, and use environment variables only if your platform has no vault. It also recommends restricted API keys over unrestricted secret keys, so a leak has a smaller blast radius.

Supabase's secure data guide says publishable keys are safe to expose when Row Level Security is enabled, and that secret and service role keys are never safe to expose because they bypass Row Level Security. Use those only on the backend.

A useful rule of thumb: if the key can charge money, send email, read private data, mint admin tokens, bypass row policies, or call a billable model API without further user consent, it does not belong in the client.

What happens when a key leaks

  • Unexpected vendor charges (LLM tokens, maps quota, SMS, email)
  • Stolen customer data or admin access if the key is privileged
  • Abuse of a contact form or inbox when SMTP credentials ship to the browser
  • Forced emergency rotation that breaks every environment still using the old key
  • Store or launch delays while the architecture gets rebuilt under pressure

Google Cloud notes that publicly exposing API keys can lead to unexpected charges or unauthorized access to your data.

Keeping the key on a server

The usual fix is small: the app talks to your API, and your API talks to vendors.

Browser / iOS / Android
        |
        |  session cookie, bearer token, or public app traffic
        v
Your API (server)
        |
        |  secret from process.env / secrets manager
        |  request validated at runtime
        v
Stripe / OpenAI / SMTP / Maps web service / …

This is often called a backend-for-frontend, a BFF, or just “the API.” The name matters less than the boundary: secrets never cross into client modules.

In practice the server reads the key from its own environment or secrets manager, builds the vendor request itself, and returns only the result the client needs. It never sends the key back. It also checks who is calling and what they sent before spending the key. A proxy that forwards any request is an open door to your vendor account, even though the key itself is never visible.

The client holds:

  • Your API base URL (public)
  • A user session or access token your API issued (still worth protecting, but it is not a vendor admin key)
  • Publishable vendor keys, when the vendor documents client use

The server holds:

  • Secret or restricted keys, SMTP credentials, webhook signing secrets
  • Database URLs and secret / service-role tokens

Controls on the public endpoint

Moving the key to the server is not enough if anyone on the internet can call your endpoint as often as they like. OWASP's API Security Top 10 lists unrestricted resource consumption as a risk: every accepted spam or abuse call can cost CPU, storage, and third-party spend.

The public edge of the API should be treated as hostile traffic:

  • Validate every body, query, and header you rely on
  • Rate-limit expensive or auth-adjacent routes
  • Prefer authenticated callers for billable vendor actions
  • Log enough to debug; never log secrets or full authorization headers
  • Use a secrets vault in production when the host provides one. Stripe documents AWS Secrets Manager, Google Cloud Secret Manager, Azure Key Vault, and HashiCorp Vault, with environment variables as a fallback when no vault exists
  • Follow Node.js security best practices and the OWASP Node.js Security Cheat Sheet for headers, dependency hygiene, and error handling that does not leak stack traces to clients

If the key is already in production

  1. Rotate the exposed credential in the vendor dashboard. Assume anyone who downloaded the build has a copy. Stripe's guidance is to treat exposure as potential compromise and rotate immediately, even if you are unsure anyone saw it.
  2. Search repos, CI logs, screenshots, and old TestFlight or Play internal builds for the old value.
  3. Move vendor calls behind your own API (or serverless functions you control). Remove the secret from client env files and from any VITE_ / NEXT_PUBLIC_ / EXPO_PUBLIC_ entry.
  4. Restrict any remaining public keys (HTTP referrers, bundle IDs, package names, API allowlists) per the vendor's docs.
  5. Ship a new web deploy or store build so old bundles stop circulating. Rotation is what actually kills the old key; the rebuild just clears up confusion.

Putting rotation off until a refactor is done leaves a live credential in every old install.

Checklist before shipping

  • No sk_, rk_, SMTP passwords, private keys, Supabase secret / service_role, or other admin tokens in client source or public env prefixes
  • Secrets loaded only in server runtime config or a secrets manager
  • Client calls your API for billable or privileged provider actions
  • Public routes validate request bodies before any vendor call
  • Publishable keys (if any) are restricted in the vendor console
  • Rate limits and auth on expensive proxy routes
  • A known path for who rotates keys, who redeploys, and who checks billing alerts

Need a server between your app and your providers?

If your web or mobile app still calls Stripe, OpenAI, or email providers with a key from the client, we can set up the server layer, rotate the exposed keys, and hand it off so your team can run it.

Sources