Skip to content

Security

Web app security checklist

These are the mistakes that turn a working app into a bill or a data leak, in the order they tend to cost: keys in the browser, an open database, API routes that trust anyone. Each takes minutes to check.

1. No secret key in the browser

Anyone can read everything the browser downloads. Open the page, press View Source or open the network tab, and search for “sk_”. A secret key in your JavaScript is a key anybody can use, on your bill. The free page check does that search for you, across your page and four more.

Public by design, fine in the page

  • Stripe publishable keys (pk_live_…, pk_test_…).
  • Supabase’s anon or publishable key, if row-level security is on (below).
  • Firebase’s web config, if your security rules are real (below).
  • A Google Maps browser key, restricted to your domains in Google Cloud.

Never in the page

  • Stripe secret or restricted keys (sk_live_, rk_live_): they can refund, charge and read every customer.
  • OpenAI, Anthropic and other AI API keys: anyone can run up your usage.
  • Supabase’s service_role key or sb_secret_ key: they bypass every row-level security rule.
  • Database connection strings with a password, cloud credentials (AWS AKIA…), GitHub tokens, private keys.

If one is exposed, in this order

  1. Rotate it now, where it was issued: Stripe’s API keys page (roll the key), your AI provider’s key settings (revoke and create a new one), Supabase’s API settings. Removing it from the code is not enough, because visitors have already downloaded it.
  2. Check its usage for the time it was public: charges, API usage, database logs.
  3. Move the call to your server, where an API route or server function reads the key from an environment variable, and have the browser call your route.
  4. Search your git history for it too. A key that was ever committed is still in every clone, and rewriting history cannot reach those copies, so rotating the key is the fix.

Is a key in your page right now?

Free. We read your page's HTML and your own scripts for Stripe, OpenAI, Anthropic, Supabase, GitHub and AWS keys, private keys and database URLs, and show only a masked value.

Check only sites you run or have permission to check.

2. Know which environment variables ship to the browser

Frameworks put a variable in the browser bundle only when its name starts with a public prefix. Give a secret one of those names and it is public.

  • Next.js: NEXT_PUBLIC_… is sent to the browser. Everything else stays on the server, readable only in server code.
  • Vite: VITE_…. Create React App: REACT_APP_…. Expo: EXPO_PUBLIC_…. Nuxt: runtimeConfig.public.
  • Keep .env files out of git (.gitignore), and set production values in your host’s dashboard.
The shape that is safe
# .env.local (never committed)
STRIPE_SECRET_KEY=sk_live_…          # server only
NEXT_PUBLIC_STRIPE_KEY=pk_live_…     # fine in the browser

// app/api/checkout/route.ts: the browser calls this, the key never leaves
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

3. The database answers only what each user may see

Supabase: row-level security on every table

The anon key in your page can query every table that does not have row-level security (RLS) turned on. With RLS on and no policy, nobody can read it; with a policy, only the rows it allows.

A table only its owner can read and write
alter table public.notes enable row level security;

create policy "own notes" on public.notes
  for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
  • Check every table in the dashboard's table editor, where an unlocked padlock means RLS is off.
  • Supabase's security advisor lists tables without RLS and functions that bypass it.
  • Check views and functions marked security definer too, because they run with their owner's rights.

Firebase: rules that are not test mode

Test-mode rules allow anyone to read and write everything until a date. Replace them with rules that check request.auth and the document’s owner, for Firestore and for Storage.

4. Every API route checks who is calling

  • Check the session on the server in every route and server action that reads or changes data. A button hidden in the interface is not a permission.
  • Check ownership as well as login: /api/invoices/42 must refuse a signed-in user who does not own invoice 42.
  • Validate input on the server (types, lengths, allowed values); the browser's validation is only a courtesy.
  • Rate-limit anything that costs money or sends mail: AI calls, SMS, sign-up, password reset.
  • Do not answer CORS with * on routes that use cookies or tokens.

5. Headers and cookies

  • Send the five response headers from the security headers check: HSTS, a Content-Security-Policy, framing protection, nosniff, a Referrer-Policy.
  • Set session cookies with HttpOnly, Secure and SameSite.
  • Do not publish source maps in production unless you are happy for anyone to read the source.

6. The rest, briefly

  • Turn on your git host's secret scanning and push protection.
  • Update dependencies with known vulnerabilities (npm audit, Dependabot).
  • Show visitors a plain error page, never a stack trace or a database message.
  • Limit upload types and sizes, and never serve uploads from the same origin as your app without a content type check.
  • Put admin pages behind a login, even when their address is unlisted.