How should Bolt apps set environment variables on Netlify?
By StackSiren · Published
Netlify environment variables that a Vite build reads under a VITE_ prefix are embedded into public JavaScript. Put secrets in unprefixed Netlify variables and read them only from Netlify Functions (or other server code) — never from the Bolt browser bundle.
Bolt (bolt.new) projects are often Vite + React and frequently deploy to Netlify. The platform's env UI is easy to misread: "available at build time" sounds private. For Vite it is not. Same rule as Are VITE_ environment variables secret?. Also check the live site for a public .env and keep Netlify security headers in place.
Build vs runtime vars on Netlify
Netlify documents variables that are:
- Used while the site builds (Vite, bundlers, static generation)
- Available to Functions / Edge Functions at runtime
- Optionally scoped to Deploy contexts (production, deploy previews, branch deploys)
For a classic Vite SPA:
| Kind | Example names | Ends up in browser? |
|---|---|---|
| Public build-time | VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY |
Yes — inlined into JS |
| Secret runtime | OPENAI_API_KEY, STRIPE_SECRET_KEY, SUPABASE_SERVICE_ROLE_KEY |
No, if only Functions read them |
| Accidental secret | VITE_OPENAI_API_KEY |
Yes — treat as leaked |
Set secrets in Site configuration → Environment variables without the VITE_ prefix, and enable them for the contexts that run Functions. Public Supabase anon/publishable values may use VITE_ on purpose — they are designed to be public, but only with correct RLS (anon key safety).
The VITE_ trap
Vite's docs are explicit: only env vars prefixed with VITE_ are exposed to client code, via import.meta.env.VITE_…. Anything else is available to Node during the build but not automatically shipped to the browser — unless you hand it to a plugin or inline it yourself.
Dangerous pattern in a Bolt/Vite app:
// UNSAFE — ships in the browser bundle after Netlify build
const openai = import.meta.env.VITE_OPENAI_API_KEY;
Safer split:
// Browser — public identifiers only
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnon = import.meta.env.VITE_SUPABASE_ANON_KEY;
// netlify/functions/chat.js — secret stays on the server
export async function handler(event) {
const key = process.env.OPENAI_API_KEY;
if (!key) return { statusCode: 500, body: "missing key" };
// call OpenAI with key; never return the key to the client
}
After deploy, search the built JS:
curl -fsS https://your-bolt-site.netlify.app/assets/*.js 2>/dev/null | head -c 0
# Or open DevTools → Sources and search for sk- / sk_live_ / service_role
grep -R "sk-" dist/assets || true
If a secret-shaped string appears in dist/, rotate it and remove the VITE_ prefix. Guides: exposed API keys, exposed env files.
Netlify Functions for secrets
- Create
netlify/functions/…(or the Functions directory your Bolt export uses). - Set
OPENAI_API_KEY(etc.) in the Netlify UI ornetlify.toml[build.environment]withoutVITE_. Prefer the UI / secret store for production values so they are not committed. - Read with
process.env.NAMEinside the Function only. - Call the Function from the browser (
/.netlify/functions/chat) with the user's session or your own auth check — do not pass the secret back. - Restrict deploy-preview access to secrets when previews are public: use context-scoped env vars so preview deploys get stub keys or none at all.
Bolt-specific UI labels change; if the in-editor "secrets" panel syncs to Netlify, still verify the deployed bundle and Function logs. Do not assume a green checkbox means the value stayed server-side.
Example: OpenAI from a Bolt UI without leaking the key
Browser form posts to your Function; the Function holds the key:
<!-- simplified -->
<form id="ask">
<textarea name="prompt"></textarea>
<button type="submit">Ask</button>
</form>
<script type="module">
document.getElementById('ask').onsubmit = async (e) => {
e.preventDefault();
const prompt = new FormData(e.target).get('prompt');
const res = await fetch('/.netlify/functions/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const data = await res.json();
console.log(data.reply);
};
</script>
// netlify/functions/chat.js
export async function handler(event) {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'POST only' };
}
const { prompt } = JSON.parse(event.body || '{}');
const key = process.env.OPENAI_API_KEY;
if (!key) return { statusCode: 500, body: 'server misconfigured' };
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: String(prompt || '').slice(0, 4000) }],
}),
});
const json = await r.json();
const reply = json.choices?.[0]?.message?.content ?? '';
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reply }),
};
}
Add auth (session cookie, Supabase JWT check, or Netlify Identity) before you expose a paid API behind a public Function. Rate-limit if the endpoint is anonymous. The important part for this article: OPENAI_API_KEY never appears in dist/.
Checklist for a Bolt → Netlify ship
- Inventory every
import.meta.env.VITE_*usage — public only. - Move provider secrets to Functions (or Supabase Edge Functions / other backend).
- Confirm Netlify publish directory is the Vite
dist(no.env/.gitin the web root). - Curl
/.envand/.git/configon the production hostname. - Add headers via
_headers/netlify.toml(Netlify security headers). - Re-scan after each deploy with StackSiren's free scan or the env file / API key checkers.
Deploy contexts (previews)
Netlify lets you scope variables to production vs deploy previews vs branch deploys. Use that:
- Production: real Stripe/OpenAI/service_role secrets for Functions only.
- Deploy previews: stub keys, or omit secrets so preview Functions fail closed rather than billing you for public preview abuse.
- Never put production service_role keys in a context that untrusted collaborators can trigger.
If a Bolt preview URL is world-readable, treat every VITE_ value and every Function secret attached to that context as potentially exposed to anyone who opens the preview.
Related reading
- Are VITE_ environment variables secret?
- Is my .env file public on my site?
- Netlify security headers
- Exposed .env / .git guide
- Exposed API keys guide
FAQ
Can I use Netlify env vars without VITE_ in browser code?
Not through import.meta.env. Vite will not expose them. You need a Function or other server path.
Is VITE_SUPABASE_ANON_KEY OK? Usually yes — the anon/publishable key is public by design. Security still depends on RLS and grants.
Do build-time secrets in netlify.toml stay private?
Values in git-committed netlify.toml are visible to anyone with repo access. Prefer the Netlify UI / sealed secrets for production keys. Build-time injection into client bundles is still a leak if prefixed VITE_.
What about NEXT_PUBLIC_ if I exported to Next?
Same class of problem as VITE_: public by design. See Is NEXT_PUBLIC safe?.
Bolt changed its env UI — is this still accurate? The Vite bundling rule is framework-level and stable. Always verify with a Sources search on the live Netlify hostname after deploy.
Fix guides for this topic
Platform checklists: Bolt