Volver al blog

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.

Carol segurança ofensiva · escreve o que a gente vê nas auditorias

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 delaanon, 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_ ou PUBLIC_ é 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:

  1. Abra seu site publicado e veja o código-fonte (Ctrl+U).
  2. Procure a tag <script src="/assets/index-xxxx.js"> — é o seu bundle.
  3. Abra esse arquivo direto no navegador.
  4. Ctrl+F e procure, um por um: sk-, sk_live, service_role, AKIA, SG.
  5. Procure também eyJ — é como todo JWT começa. Se achar mais de um, um deles pode ser o service_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 é:

  1. Revogar a chave no painel do provedor. Primeiro isto, sempre.
  2. Gerar uma nova.
  3. Mover a chamada para o backend (Edge Function, API route, serverless).
  4. 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_role do 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 o service_role do Supabase. A anon key 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.

Descubre en 30 segundos lo que tu dominio está mostrando.

Comprobación pasiva, sin tocar tu servidor. Sale una nota y una lista de hallazgos por gravedad — y tú decides si quieres seguirlo cada mes.