Skip to content

OpenAI or Stripe keys in your AI-built app? Move them to a server function

Anyone can copy a key from your frontend. Which keys are safe in the browser, how to find the ones that are not, and how to move them to a Supabase Edge Function.

4 min read

You asked your AI tool to add a chatbot, payments or email to your app. It worked on the first try, and the reason it worked so fast is often that the API key went straight into the frontend code.

Everything in the frontend is sent to every visitor’s browser. If a key is there, anyone can open the developer tools, copy it and use it, on your account and your bill. Automated scanners look for exactly these keys on public websites.

Which keys can be in the browser

Not every key is a secret. Many services issue two kinds on purpose:

Safe in the browser Never in the browser
Stripe publishable key (pk_live_…) Stripe secret or restricted key (sk_live_…, rk_live_…)
Supabase publishable or anon key Supabase secret or service role key (what to do if it leaked)
Analytics and tag IDs OpenAI, Anthropic and other AI keys (sk-…)
Map keys restricted to your domain Email sending keys (Resend re_…, SendGrid, Postmark)

The rule: if the key lets someone spend money, read private data or send something in your name, it belongs on a server.

What a leaked key costs depends on the service. An AI key can produce a large bill in hours. A Stripe secret key can read your customers and issue refunds. An email key lets someone send phishing from your domain, which also damages your delivery for months.

Find keys in your live app

Open your app, press F12, go to Sources (Chrome) or Debugger (Firefox), and search all files (Ctrl+Shift+F or Cmd+Option+F) for: sk-, sk_live, rk_live, re_, sb_secret_, service_role.

If you have the code locally, build it and search the output, which is exactly what visitors receive:

Search the production build for secret keys
npm run build
grep -rEn "sk-[A-Za-z0-9_-]{20,}|sk_live_|rk_live_|re_[A-Za-z0-9_]{20,}|sb_secret_|service_role" dist/

A common source is the environment file. In Vite projects, which includes most apps from Lovable and Bolt, every variable starting with VITE_ is copied into the browser code. In Next.js the same applies to NEXT_PUBLIC_. A secret in VITE_OPENAI_API_KEY is public, no matter that it lives in .env.

Fixing it

1. Replace the key first

Moving the code doesn’t un-leak the key. In the provider’s dashboard, create a new key and delete the old one. Do this first, even if you think nobody has seen it.

While you are there, set a monthly spending limit on AI providers. It turns a potential disaster into a capped one.

2. Store the new key as a server secret

With Supabase, Edge Functions read secrets from their environment. Add the key in the dashboard under Edge Functions → Secrets, or with the CLI:

Store the key where only server code can read it
supabase secrets set OPENAI_API_KEY=sk-your-new-key

Lovable has its own place for secrets and asks for them when an integration needs one, instead of putting them in code. On Vercel or Netlify, use environment variables without the VITE_ or NEXT_PUBLIC_ prefix, read only by server functions.

3. Call the service from a server function

The browser calls your function; your function calls the service with the secret key. A minimal Edge Function for an AI feature:

supabase/functions/ask/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';

Deno.serve(async (req) => {
  // 1. Only signed-in users may call this function.
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')! // public key: it only identifies the project
  );
  const token = (req.headers.get('Authorization') ?? '').replace('Bearer ', '');
  const { data: { user } } = await supabase.auth.getUser(token);
  if (!user) return new Response('Unauthorized', { status: 401 });

  // 2. Accept only what the feature needs.
  const { question } = await req.json();
  if (typeof question !== 'string' || question.length > 2000) {
    return new Response('Bad request', { status: 400 });
  }

  // 3. The secret key never leaves the server.
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: Deno.env.get('OPENAI_MODEL'),
      max_tokens: 500,
      messages: [{ role: 'user', content: question }]
    })
  });

  const data = await response.json();
  return Response.json({ answer: data.choices?.[0]?.message?.content ?? '' });
});

In the app, the call becomes supabase.functions.invoke('ask', { body: { question } }), and the Supabase client sends the signed-in user’s token with it.

4. Payments: let the server decide the price

For Stripe, moving the key is not enough. If the browser tells your function what to charge, someone can change the amount. The browser should send only what is being bought (a product or plan ID); the server looks up the price, creates the Checkout Session with the secret key, and returns the link. Then confirm payments through webhooks verified with Stripe’s signature, never from a “success” page the user can open directly.

Before every release

  • Search the build for the patterns above.
  • Keep secrets out of any variable with a public prefix.
  • When the AI suggests putting a key in the frontend “for now”, ask it for a server function instead.

If your app handles payments, AI features or personal data, our free Security Check looks for exposed keys, open functions and unsigned webhooks from the outside, read-only, and sends you the most serious issues in writing.

API keysAI-built appsSupabaseBolt

This article is also available in: PortuguêsEspañol日本語中文

All articles