Sua chave da OpenAI está no bundle: o erro nº 1 de quem publica com Lovable, Bolt e v0
Prefixo VITE_ e NEXT_PUBLIC_ não é "variável de ambiente" — é texto compilado dentro do JavaScript que qualquer visitante baixa. Veja quais chaves podem ser públicas, quais nunca podem, e como checar o seu app em dois minutos.
O app funciona. O deploy passou. O domínio está no ar e o primeiro usuário já entrou.
E a sua chave da OpenAI está a três cliques de qualquer visitante — dentro do arquivo JavaScript que o navegador dele acabou de baixar.
Isso não é hipótese: é um dos achados que mais aparece quando a gente aponta uma auditoria para um site publicado por assistente de IA. E a causa quase nunca é descuido — é uma confusão específica, previsível, entre variável de ambiente e segredo.
Por que um app vibecoded expõe chave com tanta facilidade
Lovable, Bolt, v0 e Replit geram, por padrão, um frontend que fala direto com os serviços: o React chama o Supabase, chama a OpenAI, chama o Stripe. Não há backend no meio.
Essa arquitetura é ótima para sair do zero em uma tarde. Ela tem uma consequência que ninguém anuncia: se o frontend precisa da credencial para fazer a chamada, a credencial vai junto para o navegador. Não existe lugar seguro no browser. Não existe "escondido no build". Minificar não é criptografar — é só deixar feio.
O modelo mental que quebra aqui é o do .env. No servidor, .env é segredo. Em build de frontend, .env é uma lista de coisas que serão coladas dentro do arquivo público.
Quais chaves podem aparecer no frontend — e quais nunca
Esta é a tabela que resolve a maior parte dos casos. Vale colar no README do seu projeto.
| Credencial | Pode ir pro frontend? | Por quê |
|---|---|---|
| Supabase anon key | Sim | É pública por desenho. Quem protege os dados é o RLS (Row Level Security), não o segredo da chave |
| Supabase service_role | Nunca | Ignora o RLS por completo. Quem tem essa chave lê e escreve tudo, em qualquer tabela |
Stripe pk_live_... |
Sim | Chave publicável. É para isso que ela existe |
Stripe sk_live_... |
Nunca | Chave secreta: cria cobrança, emite reembolso, lê cliente |
OpenAI sk-... / sk-proj-... |
Nunca | Não há versão pública. Qualquer chamada sai na sua fatura |
Anthropic sk-ant-... |
Nunca | Mesmo caso |
AWS AKIA... |
Nunca | Credencial de conta, não de aplicação |
SendGrid SG.... |
Nunca | Quem a tem manda e-mail assinado como você |
| Firebase config (apiKey) | Sim | É identificador de projeto, não credencial. Quem protege é a Security Rule |
Repare no padrão: os serviços que oferecem chave pública dizem isso no nome dela — anon, pk de publishable, public. Quando o nome não avisa, presuma que não pode.
O par mais perigoso da lista é o do Supabase, porque as duas chaves são JWT, se parecem na tela e ficam lado a lado no painel. Trocar uma pela outra não gera erro nenhum: o app funciona melhor, porque o service_role passa por cima de toda regra que estava barrando você durante o desenvolvimento. É exatamente por isso que a troca acontece — alguém bate numa permissão negada, troca a chave, o erro some, e o RLS inteiro foi desligado sem nenhum aviso.
A armadilha do VITE_ e do NEXT_PUBLIC_
Se você lembrar de uma frase só deste post, lembre desta:
Toda variável com prefixo
VITE_,NEXT_PUBLIC_,REACT_APP_ouPUBLIC_é substituída por texto literal dentro do bundle durante o build.
Não é lida em tempo de execução. Não fica no servidor. O compilador troca import.meta.env.VITE_OPENAI_KEY pela string da chave, e é essa string que vai para o arquivo .js que o navegador baixa.
O prefixo não é etiqueta de organização — é uma declaração de que aquilo é público. As ferramentas exigem o prefixo justamente para você ter de dizer "sim, eu sei". O que ninguém explica é o que a resposta significa.
E é aqui que o assistente de IA piora a situação sem má intenção: quando o app quebra com "variável indefinida", a correção mais rápida e mais provável de ser sugerida é adicionar o prefixo — que é o que faz a variável aparecer no bundle. O erro some. O vazamento nasce.
Como checar o seu app agora, em dois minutos
Sem instalar nada:
- Abra seu site publicado e veja o código-fonte (
Ctrl+U). - Procure a tag
<script src="/assets/index-xxxx.js">— é o seu bundle. - Abra esse arquivo direto no navegador.
Ctrl+Fe procure, um por um:sk-,sk_live,service_role,AKIA,SG.- Procure também
eyJ— é como todo JWT começa. Se achar mais de um, um deles pode ser oservice_role.
Achou? A chave está comprometida. Não porque alguém já a usou, mas porque você não tem como saber se usou.
Trocar o código sem trocar a chave não resolve nada: qualquer cópia do bundle antigo — cache de CDN, Wayback Machine, aba aberta de um visitante — continua valendo. A ordem correta é:
- Revogar a chave no painel do provedor. Primeiro isto, sempre.
- Gerar uma nova.
- Mover a chamada para o backend (Edge Function, API route, serverless).
- Só então publicar de novo.
E o source map?
Se o build subiu com source map (.js.map), a coisa é maior que uma chave: quem baixar o mapa reconstrói o código-fonte original, com nomes de variável, comentários e estrutura de pastas. É a planta do prédio junto com a chave da porta.
Source map em produção é útil para depurar erro real de cliente. Só que ele precisa ser enviado ao serviço de monitoramento de erro, não servido publicamente. É diferença de uma linha na configuração do build.
"Mas o meu app é pequeno, ninguém vai olhar"
Ninguém vai olhar você. O que olha é varredura automatizada, e ela não escolhe alvo por relevância — escolhe por padrão de texto.
Chave de provedor tem formato fixo e reconhecível: AKIA seguido de 16 caracteres maiúsculos, SG. com dois blocos de tamanho exato, sk-ant- com 90 caracteres. Isso é achável com expressão regular, em escala, sem nenhuma inteligência envolvida. É o mesmo mecanismo que faz um site novo receber tentativa de login em /wp-admin nas primeiras horas de vida — como detalhamos na janela entre o patch e a exploração em massa.
E o prejuízo não é proporcional ao tamanho do app:
- Chave de LLM drenada — o custo é o de quem a usou, não o do seu tráfego. A fatura cresce enquanto o app segue funcionando normalmente, que é o que atrasa a descoberta.
service_roledo Supabase vazado — leitura e escrita em todas as tabelas, RLS ignorado. Base de usuários inteira, com e-mail e o que mais você guardar.sk_live_do Stripe vazado — leitura de clientes e movimentação de cobrança.
Nos três casos o incidente também é de dado pessoal, com as obrigações de LGPD que vêm junto — e LGPD não é só a página de privacidade.
O que o Sentinela faz aqui
A varredura de bundle é uma das checagens da auditoria de segurança, e foi escrita justamente para site publicado por assistente de IA. O que ela faz, exatamente:
- Baixa o HTML da raiz e extrai os bundles JS do mesmo domínio (validado por domínio registrável, então CDN de terceiro não entra).
- Descobre também os chunks do Next.js em
/_next/static/chunks/— o segredo costuma estar num pedaço carregado sob demanda, não no arquivo principal. - Procura os padrões da tabela lá de cima: OpenAI, Anthropic, Stripe
sk_live_, AWS, SendGrid e oservice_roledo Supabase. Aanonkey não vira achado — ela é pública por desenho, e alarme falso treina gente a ignorar alarme. - Detecta source map exposto.
- Antes de tudo, detecta se há WAF ou desafio na frente. Isso importa mais do que parece: sem essa checagem, um bloqueio do Cloudflare devolveria "nada encontrado", e um relatório limpo por não ter conseguido olhar é pior que relatório nenhum.
Quando acha, o achado nunca mostra a chave inteira — no máximo os quatro primeiros caracteres, o suficiente para você saber qual é (sk-a, AKIA) sem que o PDF vire um segundo vazamento. O fim da chave nunca aparece, porque é justamente o que os painéis imprimem para identificar a conta.
E o que ele não faz, para você não descobrir depois: não testa se a chave ainda é válida, não a revoga por você e não roda como hook de pre-commit na sua máquina. A auditoria olha o que está publicado.
O bundle é só a metade que está visível
Se a chave chegou ao bundle, ela quase certamente passou por um commit antes. E aí o problema muda de lugar: trocar o arquivo não tira o segredo do histórico do Git, que é o assunto do próximo post desta série.
Como ligar isso na sua conta
A varredura de bundle roda junto com a auditoria de segurança do domínio — você aponta o site e ela olha de fora, como um visitante anônimo olharia.
O plano Business é o ponto em que isso deixa de ser checagem avulsa e vira rotina: auditorias ilimitadas (então você audita a cada deploy, não quatro vezes por mês), histórico de 365 dias para provar que o problema foi corrigido e quando, e — o que interessa a quem escreve código — a conexão do repositório Git, que leva a varredura para dentro do histórico e para o alerta minutos depois do push.
Ver planos e assinar o Business — em real, com nota fiscal, e sem precisar instalar nada no seu servidor.
Descubra em 30 segundos o que o seu domínio está mostrando.
Checagem passiva, sem tocar no seu servidor. Sai nota e lista de achados por gravidade — e você decide se quer acompanhar todo mês.