How to Move Exposed Lovable API Keys Into Supabase Edge Functions
If a secret key — Stripe, OpenAI, or your Supabase service_role key — sits anywhere in your client code or a VITE_ environment variable, it is already public: anyone can open DevTools and read it. The fix is to move every secret-tier key out of the browser bundle and into a Supabase Edge Function that runs server-side. This guide shows you which keys are safe to expose, which are not, and the exact pattern to relocate them.
By Founder Name · Last verified: 2026-06-25
How do I know if my Lovable API keys are exposed client-side?
Open your deployed app, press F12, and search the Sources or Network tab for the key prefix — sk_live, sk-, or eyJ for a JWT. If it appears, it is shipped to every visitor. Any environment variable prefixed VITE_ (or NEXT_PUBLIC_) is compiled directly into the JavaScript bundle the browser downloads. There is no such thing as a hidden client-side secret.
Lovable generates a working app by wiring keys wherever the code that needs them lives — and in a Vite single-page app, that code runs in the browser. So when a prompt adds a Stripe checkout or an OpenAI call, the AI often drops the key straight into a VITE_ variable or a fetch header in client code. The preview works, the demo works, and the secret leaks the moment you deploy.
This is a specific instance of The Vanishing Env-Var problem in reverse: instead of a key that disappears at deploy time, you have a key that should have stayed server-side but got published to the bundle. The functionality is correct; the trust boundary is wrong.
| Key | Safe in client? | Where it belongs | Damage if leaked |
|---|---|---|---|
| Supabase anon (publishable) key | Yes | Client createClient() — RLS protects rows | Low — RLS still enforced |
| Supabase service_role key | Never | Edge Function env only | Total — bypasses all RLS, full DB read/write |
| Stripe secret key (sk_live / sk_test) | Never | Edge Function env only | Charges, refunds, payout access on your account |
| Stripe publishable key (pk_) | Yes | Client — designed to be public | None — public by design |
| OpenAI / Anthropic API key | Never | Edge Function env only | Unbounded billing run up against your key |
| Resend / SendGrid / Twilio key | Never | Edge Function env only | Spam, fraud, and cost on your account |
| Database connection string / password | Never | Edge Function env only | Direct unauthenticated DB access |
Related: anon vs service_role key explained · run the full 12-point security checklist
What's the difference between the anon key and the service_role key?
Supabase gives you two keys, and the distinction is the whole game. The anon (publishable) key is designed to live in the browser — it respects Row-Level Security, so a user can only touch rows your policies allow. The service_role key bypasses RLS entirely and can read or write every row in your database. One is safe to expose; the other is the keys to the building.
The trap is that both keys look like long opaque JWT strings, so it is easy to paste the wrong one into createClient(). If the service_role key ends up in your client constructor — or in a VITE_SUPABASE_SERVICE_ROLE_KEY variable — every visitor effectively has admin access to your data, RLS policies or not. Search your repo for service_role before you do anything else.
Rule of thumb: the anon key belongs in client code and is enforced by your RLS policies. The service_role key belongs only inside an Edge Function's secret environment, where it never reaches the browser. If you have not yet enabled RLS, the anon key alone is not enough protection — fix that first.
Why must Stripe, OpenAI, and service_role keys never live in client code?
Because client code is downloadable. The browser must receive every byte it executes, so anything in the bundle — keys included — can be extracted in seconds with DevTools or a curl request. A leaked Stripe secret key lets an attacker issue refunds and read your payment data. A leaked OpenAI key lets them run thousands of dollars of inference on your bill before you notice.
Secret-tier keys share one property: they authenticate as you, with no per-user scoping. The anon key is paired with RLS that limits what each user can do. A Stripe or OpenAI key has no such limiter — whoever holds it can act with your full account privileges. That is exactly why they must execute only on a server you control, where the key stays in memory and never crosses the network to a client.
Minification does not help. Renaming variables and stripping whitespace does not remove the string sk_live_... from the bundle — it just makes the surrounding code harder to read. Automated scanners crawl deployed sites looking for these exact prefixes, which is why exposed keys are often abused within hours, not weeks.
How do I move a secret key into a Supabase Edge Function?
The pattern is the same for every secret: store the key as an Edge Function secret, write a small function that uses it server-side, and have your client call that function instead of the third-party API directly. The browser only ever talks to your function, which holds the key. Below is the move applied to an OpenAI call — the same three steps work for Stripe or any service_role operation.
A minimal Edge Function looks like this. Save it as supabase/functions/chat/index.ts:
import { serve } from "https://deno.land/std/http/server.ts"; serve(async (req) => { const { prompt } = await req.json(); const key = Deno.env.get("OPENAI_API_KEY"); // secret, server-side only const res = 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: prompt }], }), }); const data = await res.json(); return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" }, }); });
On the client, you never see the key — you only invoke the function: const { data } = await supabase.functions.invoke("chat", { body: { prompt } }); The same shape applies to Stripe: the Edge Function holds STRIPE_SECRET_KEY, creates the checkout session, and returns only the session URL to the browser.
- Remove the key from client code. Delete the VITE_OPENAI_KEY (or similar) variable and any place the raw key appears in a fetch header in src/.
- Store it as an Edge Function secret. In the Supabase dashboard go to Edge Functions → Manage secrets, or run: supabase secrets set OPENAI_API_KEY=sk-...your-key. This value is available only to your functions, never to the browser.
- Write a function that reads the secret from Deno.env. The key is loaded at runtime on Supabase's servers: const key = Deno.env.get("OPENAI_API_KEY"); then make the third-party request inside the function and return only the result.
- Call the function from the client with the anon key. Use supabase.functions.invoke("chat", { body }) — the browser sends a request to your function, the function calls OpenAI, and the secret never leaves the server.
- Rotate the old key. The exposed key is compromised even after you remove it from code, because it was already public. Generate a new one in the provider dashboard and revoke the old.
- Verify nothing leaked. Rebuild, open DevTools, and grep the deployed bundle for sk- and service_role to confirm the key is gone.
How do I handle CORS so my client can still call the Edge Function?
Edge Functions enforce CORS, so a browser request from your app will be blocked unless the function returns the right headers and answers the preflight OPTIONS request. The mistake is opening it to a wildcard origin to make the error go away — that lets any site call your function. Name your specific production domain instead, and handle the preflight explicitly.
When the browser sees a cross-origin POST, it first sends an OPTIONS request. If your function does not answer it with Access-Control-Allow-Origin and Access-Control-Allow-Headers, you get a CORS error in the console and the real request never fires. Add a short header block at the top of the function and return it on both the OPTIONS branch and the final response.
const corsHeaders = { "Access-Control-Allow-Origin": "https://yourapp.com", "Access-Control-Allow-Headers": "authorization, content-type", }; if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } // ...and spread ...corsHeaders into your final Response headers.
Resist the urge to set the origin to a literal asterisk just to unblock yourself. A wildcard origin combined with a function that touches paid APIs means any website on the internet can drive cost and actions through your key. Lock it to your domain.
How do I verify the keys are actually gone after the move?
Removing a key from one file is not proof it left the bundle — it can survive in git history, in a second copy, or in a built artifact. Run a short verification pass against the deployed build, not the editor. The goal is zero matches for any secret prefix in the JavaScript the browser actually downloads, plus a clean git history.
- Search the source tree: grep -rin "service_role\|sk_live\|sk-\|OPENAI\|STRIPE_SECRET" src/ — expect no raw key values, only references to invoke().
- Search git history: git log --all -p | grep -iE "sk_live|service_role|sk-[A-Za-z0-9]" — any hit means the key persists in history even if deleted from HEAD; rotate it.
- Inspect the deployed bundle: open the live site, F12 → Sources, and Ctrl-F for sk_live and eyJ inside the .js files; the secret keys must not appear.
- Confirm secrets are set on the server: supabase secrets list should show OPENAI_API_KEY, STRIPE_SECRET_KEY, etc., as Edge Function secrets.
- Exercise the feature end to end: trigger the checkout or AI call and confirm it still works through invoke() with only the anon key present in the client.
| Surface | Command / check | Pass condition |
|---|---|---|
| Source code | grep -rin 'sk_live|service_role' src/ | No raw key values |
| Git history | git log --all -p | grep -iE 'sk_live|service_role' | No matches (else rotate) |
| Deployed bundle | DevTools → Sources → find sk_live / eyJ | Secret keys absent |
| Server secrets | supabase secrets list | Secret keys present server-side |
| Live feature | Trigger checkout / AI call | Works via invoke(), anon key only |
When should I bring in an expert to audit my key exposure?
If you have found one exposed secret, assume there are more — exposure is rarely isolated, and a Lovable app that leaked a Stripe key has often also left RLS off and the service_role key reachable. If you handle real payments or user data, a one-time security audit confirms every secret is server-side, every key is rotated, and the trust boundary holds before an attacker tests it for you.
A senior engineer will map every secret in your project, move each one into a properly scoped Edge Function, rotate the compromised keys, lock CORS to your domain, and verify the deployed bundle is clean — then hand you a written report of what was exposed and what was fixed. That is far cheaper than the fraudulent charges or runaway API bills a single leaked key can produce.
This pairs naturally with an RLS review: keys and policies are the two halves of the same trust boundary. Securing one without the other leaves a gap. Book a call and we will tell you within the audit exactly which keys are reachable today.
Related: book a Lovable security audit · fix users seeing each other's data (RLS)
Frequently asked questions
Are my Lovable API keys really exposed if they're in a VITE_ environment variable?
Is the Supabase anon key safe to expose in client code?
What happens if my Stripe secret key gets leaked?
Can't I just minify or obfuscate the key so people can't find it?
How do I move an OpenAI key out of my Lovable app's client code?
Do I need to rotate a key after I move it to an Edge Function?
Why am I getting a CORS error after moving my key to an Edge Function?
Where should I store the Supabase service_role key in a Lovable app?
How do I check whether a leaked key is still in my git history?
I found one exposed key — should I get a full security audit?
Talk to a senior engineer — not a salesperson.
Book a free 30-minute audit call. We'll diagnose what's wrong and tell you exactly what it costs to fix.