Guide

How to find secrets exposed to the browser (Next.js, Vite and others)

Hard-coded keys, server keys with a public prefix, committed .env files and LLM keys in client code: how to spot each and fix it.

Anything that reaches the browser can be read by every visitor, and anything committed to git can be read by everyone with access to the repo, its forks and its history. These are the four ways keys usually leak.

1. A key written into the code

// Bad
const openai = new OpenAI({ apiKey: "sk-proj-abc123..." });
// Good
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

Bots scan public repos for keys within minutes of a push. If a key was ever committed, rotate it: deleting the line leaves it in git history.

2. A server key with a public prefix

Frameworks copy any variable with a public prefix into the JavaScript every visitor downloads: NEXT_PUBLIC_ in Next.js, VITE_ in Vite, REACT_APP_ in Create React App, and EXPO_PUBLIC_, NUXT_PUBLIC_, GATSBY_ and PUBLIC_ elsewhere.

// Bad
// .env
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ...

// components/AdminPanel.tsx
"use client";
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!);
// Good
// .env
SUPABASE_SERVICE_ROLE_KEY=eyJ...

// lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } }
);

The Supabase service-role key ignores row-level security, so anyone who opens dev tools gets your whole database. import "server-only" makes the build fail if a client file ever imports it. This applies even when .env.local is git-ignored: the value is still bundled.

3. A committed .env file

# .gitignore
.env
.env.local
.env.*.local
# .env.example (committed, no values)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

If a .env file was ever committed, run git rm --cached .env, commit, and then rotate every key that was in it. Adding it to .gitignore afterwards doesn't remove it from history.

4. An LLM key used in the browser

// Bad
"use client";
const openai = new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_KEY, dangerouslyAllowBrowser: true });

Call the model from a route handler or server action instead, with the key in a variable that has no public prefix, and have the browser call your route. Otherwise someone will copy the key from your bundle and spend your credit. Add a login check and a rate limit to that route too (see AI features).

Checking it automatically

Seaworthy flags all four: keys in files by each provider's key format (SW-S01), secrets with a public prefix in .env files and the code that reads them (SW-S03), committed or un-ignored .env files (SW-S02) and LLM keys in client code (SW-S04). It tells a Supabase anon key, which is meant to be public, apart from a service-role key. When you scan a repository's top folder it also reads git history, so a key committed and later deleted is reported with the commit that added it. Run it with:

npx seaworthy-cli

These fixes reduce common risks. They are not a security audit or a guarantee.

All guides