Pular para o conteúdo

Guia

Sua chave de API de LLM provavelmente está no bundle do frontend

Por que variáveis VITE_ e NEXT_PUBLIC_ vão parar no navegador, como procurar uma chave da OpenAI ou da Anthropic vazada no build e a rota de servidor que resolve isso.

· 7 min de leitura · Pela equipe do vetkit

Uma chave grande exposta dentro de uma janela de navegador de vidro; um robô lustra o vidro sem perceber, enquanto um desenvolvedor aponta alarmado e uma mão de luva se aproxima da chave.Uma chave grande exposta dentro de uma janela de navegador de vidro; um robô lustra o vidro sem perceber, enquanto um desenvolvedor aponta alarmado e uma mão de luva se aproxima da chave.

Você pede a um assistente que adicione uma caixa de chat ao seu app React. Ele instala o SDK da OpenAI, lê a chave de import.meta.env.VITE_OPENAI_API_KEY, e o recurso funciona de primeira. O problema é que ele também funciona para qualquer pessoa que abra as ferramentas de desenvolvedor do navegador, encontre a chave no seu JavaScript e comece a enviar requisições na sua conta.

Esse é um dos erros mais comuns que vemos em projetos assistidos por IA, e é fácil deixá-lo passar na revisão porque nada parece errado. A chave não está hard-coded. Ela fica em um arquivo .env corretamente ignorado pelo git. O código a lê de uma variável de ambiente, que é o que todo mundo diz para fazer.

Como isso acontece

Ferramentas de build de frontend não têm variáveis de ambiente em tempo de execução. O navegador executa um bundle estático. Então, quando o seu código lê import.meta.env.VITE_SOMETHING ou process.env.NEXT_PUBLIC_SOMETHING, o bundler substitui essa expressão pelo valor literal durante o build. Depois do build, não existe mais variável nenhuma, só uma string parada em um arquivo JavaScript que todo visitante baixa.

Os frameworks são claros quanto a isso. O Next.js diz que as variáveis públicas são inseridas em todo JavaScript enviado ao navegador. O Vite só expõe ao código do cliente as variáveis com o prefixo VITE_ justamente para que você não vaze as outras por acidente. O prefixo é o opt-in. Quando um assistente escolhe VITE_OPENAI_API_KEY porque o nome sem prefixo não funcionou no navegador, ele fez o opt-in por você.

Existe também uma variante mais discreta. Alguns templates iniciais copiam a chave para o bundle com a opção define do Vite, por exemplo 'process.env.GEMINI_API_KEY': JSON.stringify(...) em vite.config.ts. A variável não tem prefixo público, então parece segura, mas o efeito é exatamente o mesmo.

Os SDKs oficiais tentam impedir isso. O SDK da OpenAI para Node se recusa a rodar em um navegador a menos que você passe dangerouslyAllowBrowser: true, e o README dele explica o motivo: isso expõe as suas credenciais secretas no código do lado do cliente. Às vezes os assistentes adicionam essa flag para fazer um erro sumir. Outros clientes, como chamadas HTTP genéricas ou SDKs sem verificação de navegador, não avisam nada.

Quais variáveis são públicas

Se uma variável começa com um destes prefixos, presuma que o valor dela pode ser lido por qualquer pessoa que carregue o seu app:

  • VITE_ no Vite (e nos frameworks construídos sobre ele)
  • NEXT_PUBLIC_ no Next.js
  • REACT_APP_ no Create React App
  • EXPO_PUBLIC_ no Expo e nos apps React Native construídos com ele
  • VUE_APP_ no Vue CLI e GATSBY_ no Gatsby
  • PUBLIC_ no Astro e nos módulos de env públicos do SvelteKit

Esses prefixos servem para valores que devem ser públicos, como o ID de um site de analytics ou a URL da sua própria API. Nunca servem para um segredo de provedor: OpenAI, Anthropic, Google Gemini, Mistral, uma chave secreta do Stripe, uma service role key de banco de dados.

Verifique o seu build

O teste mais rápido é fazer o build do app e procurar o prefixo da chave na saída. Chaves de projeto da OpenAI começam com sk-proj-, chaves da Anthropic com sk-ant- e chaves de API do Google com AIza.

bash
npm run build# Vite writes to dist/, Next.js static chunks live in .next/static/grep -rEo "sk-(proj|ant)-[A-Za-z0-9_-]{8}|AIza[0-9A-Za-z_-]{8}" dist .next/static 2>/dev/null

Se isso imprimir qualquer coisa, a chave está no seu bundle. Olhe um site em produção do mesmo jeito: abra as ferramentas de desenvolvedor, vá até a aba Sources ou Network e pesquise nos scripts carregados. Se você consegue encontrá-la em trinta segundos, um script que varre sites procurando exatamente isso também consegue.

A correção

Mova a chamada ao provedor para um servidor que você controla e deixe o navegador conversar com esse servidor. No Next.js, isso é um route handler. Em uma single-page app com Vite, pode ser uma função serverless no mesmo host. A chave mantém um nome sem prefixo, então o bundler nunca a insere no código.

app/api/chat/route.ts
import OpenAI from 'openai'; // Server-only: no NEXT_PUBLIC_ prefix, never shipped to the browser.const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function POST(request: Request) {  const user = await requireSignedInUser(request); // your auth check  await rateLimit(user.id); // your limiter: per user, per minute   const { message } = await request.json();  if (typeof message !== 'string' || message.length > 4000) {    return Response.json({ error: 'Invalid message' }, { status: 400 });  }   const response = await client.responses.create({    model: 'gpt-5-mini',    input: message,    max_output_tokens: 800,  });  return Response.json({ reply: response.output_text });}

Os três detalhes em torno da chamada importam tanto quanto movê-la. Sem uma verificação de autenticação, a sua rota vira um proxy gratuito e anônimo para o modelo, que é o mesmo vazamento com passos extras. Sem rate limit, um único usuário logado ainda pode gerar uma conta enorme. Sem um limite de saída, uma única requisição pode gerar muito mais tokens do que o seu recurso precisa.

Mantenha também o nome do modelo e os limites no servidor

Se o navegador envia o nome do modelo ou max_output_tokens, um visitante pode alterá-los. Defina esses valores no servidor e aceite do cliente apenas a mensagem do usuário.

Se a chave já foi publicada

Remover a variável do código não resolve sozinho. Builds antigos continuam em cache nos navegadores e nas CDNs, e a chave pode já ter sido copiada. Trate-a como comprometida:

  1. Crie uma nova chave no painel do provedor e revogue a antiga.
  2. Coloque a nova chave apenas nas configurações de ambiente do servidor, com um nome sem prefixo público.
  3. Verifique na página de uso do provedor se há tráfego que você não reconhece e defina um limite de gastos.
  4. Faça o redeploy e repita a busca no build acima para confirmar que o novo bundle está limpo.

Se a chave chegou a ser commitada no git, rotacione-a mesmo que você tenha apagado o arquivo. O histórico do git guarda o valor antigo.

Pegue o problema antes da publicação

É fácil automatizar a prevenção desse erro. Um scanner de segredos pega chaves commitadas no repositório. Mas ele não enxerga uma chave que fica em um arquivo .env ignorado, então você também precisa de uma verificação que leia o código e perceba uma variável pública sendo passada para um cliente de LLM.

Esse segundo padrão é uma das regras de segurança de LLMs e agentes nos scans completos do vetkit: ela sinaliza um valor de uma variável de ambiente do lado do cliente (por exemplo import.meta.env.VITE_* ou process.env.NEXT_PUBLIC_*) que chega a um cliente de SDK de LLM no código do navegador, e explica como mover a chamada para o servidor. O Security Quick Scan gratuito do vetkit cobre segredos commitados, dependências com vulnerabilidades conhecidas e a postura básica de segurança. Nenhum dos dois substitui uma olhada rápida na saída do seu próprio build, que é a evidência mais direta que você pode ter.

Solicite acesso antecipado

Conte um pouco sobre sua equipe e os repositórios que você quer auditar. Recebemos equipes em pequenos grupos e responderemos por e-mail.

Usamos esses dados apenas para responder à sua solicitação. Política de privacidade