Vulnerabilidade · Frontend

Por que API keys aparecem expostas no JavaScript do meu app?

Publicado em 27 mar 2026·Leitura: ~10 min

Chaves de API aparecem expostas no JavaScript porque foram declaradas com o prefixo NEXT_PUBLIC_ (Next.js) ou VITE_ (Vite/Bolt/Lovable). Esses prefixos instruem o bundler a embutir o valor da variável diretamente no código JavaScript que é enviado ao navegador — qualquer pessoa que inspecionar os arquivos estáticos do seu site verá a chave em texto puro. Não é um bug do framework: é o comportamento esperado para variáveis intencionalmente públicas. O problema é usar esses prefixos para chaves que deveriam ser privadas.

Como o Next.js e o Vite transformam variáveis em código público

Durante o processo de build, o Next.js varre todo o código em busca de referências a process.env.NEXT_PUBLIC_* e substitui cada ocorrência pelo valor literal da variável. O resultado é um arquivo JavaScript estático onde, no lugar de process.env.NEXT_PUBLIC_OPENAI_API_KEY, aparece a própria chave: sk-proj-abc123.... Esse arquivo é servido publicamente pelo CDN ou servidor.

Ferramentas de vibe coding como Lovable e Bolt frequentemente geram código usando esses prefixos para todas as variáveis de ambiente — inclusive as que deveriam ser privadas. O motivo é simples: é o padrão mais comum nos exemplos de documentação que alimentam o treinamento dos modelos.

# .env.local

// .env.local — ❌ NEXT_PUBLIC_ torna a variável pública no bundle
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-abc123...
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_xyz...
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...
// .env.local — ✅ sem NEXT_PUBLIC_, disponível apenas no servidor
OPENAI_API_KEY=sk-proj-abc123...
STRIPE_SECRET_KEY=sk_live_xyz...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...

// A chave anon e a URL são realmente públicas — NEXT_PUBLIC_ correto aqui:
NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...

Os 3 caminhos pelos quais chaves chegam ao bundle

1. Prefixo NEXT_PUBLIC_ ou VITE_ em variável privada

O mais comum. A variável é declarada com o prefixo público e o bundler a injeta no código estático. Qualquer chave declarada assim — OpenAI, Stripe secret, Supabase service role, SendGrid, Resend — fica visível no bundle final.

2. Chave hardcoded diretamente no componente

Acontece quando o desenvolvedor cola a chave diretamente no código para "testar rápido" e esquece de mover para variável de ambiente. Ferramentas de vibe coding às vezes geram esse padrão ao criar exemplos de integração de API.

3. Source maps habilitados em produção

Source maps mapeiam o bundle minificado de volta ao código-fonte original. Se habilitados em produção — o que o Vercel faz por padrão em alguns planos — expõem não só as chaves mas toda a lógica do código, incluindo variáveis que não usam o prefixo público mas aparecem no fluxo de execução.

O que um atacante pode fazer com cada tipo de chave exposta

OpenAI (sk-proj-...)Alto — financeiro

Usa sua cota para gerar conteúdo, treinar modelos ou fazer chamadas em massa. Casos documentados de cobranças de US$4.000–8.000 em um único fim de semana. A conta pode ser suspensa por abuso antes que você perceba.

Supabase service_role keyCrítico — acesso total ao banco

Ignora o Row Level Security completamente. Permite ler, inserir, atualizar e deletar qualquer dado de qualquer tabela. Equivale a ter o login de administrador do banco de dados.

Stripe secret key (sk_live_...)Crítico — financeiro e dados

Permite criar cobranças, emitir reembolsos, listar todos os clientes e dados de pagamento, criar links de pagamento fraudulentos sob seu nome.

SendGrid / Resend API keyMédio — reputação e cota

Permite enviar e-mails em seu nome — phishing, spam, campanhas maliciosas. Seu domínio pode ser banido por provedores de e-mail.

Como verificar se seu app tem chaves expostas agora

Você não precisa de uma ferramenta especial para uma verificação inicial. Faça um build local e inspecione o output — ou use o DevTools no ambiente de produção.

# Após um build local (npm run build), verifique os arquivos estáticos:
grep -r "sk-" .next/static/
grep -r "eyJhbGci" .next/static/
grep -r "NEXT_PUBLIC_" .next/static/

# Ou inspecione via DevTools:
# F12 → Sources → webpack:// → busque por "sk-", "token", "secret", "key"

Se qualquer desses comandos retornar resultado, você tem chaves expostas. Não existe falso positivo aqui: se aparece no bundle, qualquer visitante pode ver.

Como corrigir: o padrão de API route proxy

A solução é criar uma API route no Next.js que funciona como proxy: o frontend chama sua própria rota, que por sua vez chama o serviço externo usando a chave guardada no servidor. A chave nunca sai do servidor.

❌ Frontend chamando serviço externo diretamente

// ❌ Chama a OpenAI diretamente do componente React — chave exposta no bundle
async function gerarTexto(prompt: string) {
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
  });
  return res.json();
}

✅ API route no servidor (app/api/gerar-texto/route.ts)

// ✅ app/api/gerar-texto/route.ts — chave fica no servidor
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { prompt } = await request.json();

  // Validação básica antes de chamar o serviço externo
  if (!prompt || typeof prompt !== 'string' || prompt.length > 2000) {
    return Response.json({ error: 'Prompt inválido' }, { status: 400 });
  }

  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      // process.env sem NEXT_PUBLIC_ — nunca vai para o cliente
      'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
  });

  const data = await res.json();
  return Response.json(data);
}

✅ Frontend chamando sua própria API route

// ✅ Componente chama sua própria API route — sem chave no bundle
async function gerarTexto(prompt: string) {
  const res = await fetch('/api/gerar-texto', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt }),
  });
  return res.json();
}

Atenção: rate limiting na API route

Ao criar uma API route que acessa um serviço pago, adicione rate limiting por IP ou por usuário autenticado. Sem isso, qualquer pessoa pode chamar sua rota em loop e gerar custos — mesmo com a chave protegida no servidor.

E se a chave já estiver no histórico do Git?

Deletar o arquivo .env e fazer um novo commit não resolve. O histórico do Git preserva todos os commits anteriores — qualquer pessoa com acesso ao repositório pode recuperar o arquivo deletado. Para repositórios públicos, bots já terão capturado a chave em segundos após o push original.

A ordem correta de ação: (1) revogar a chave no painel do serviço — imediatamente, antes de qualquer outra coisa. (2) emitir uma nova chave e guardá-la corretamente. (3) remover do histórico do Git com git-filter-repo. (4) force-push para sobrescrever o histórico remoto.

# Verificar se .env foi commitado no histórico
git log --all --full-history -- .env .env.local .env.production

# Ver o conteúdo de um commit específico que removeu o arquivo
git show <hash>:.env

# Remover definitivamente do histórico (após revogar a chave)
pip install git-filter-repo
git filter-repo --path .env --invert-paths
git filter-repo --path .env.local --invert-paths

Verifique se seu app tem chaves expostas no bundle

O QuickScan analisa os arquivos JavaScript públicos do seu domínio e identifica credenciais expostas em minutos — sem cadastro.

Escanear meu app agora — grátis

Sem cadastro · Resultado por e-mail em minutos

Leia também