Client secret do OAuth no bundle do front: como achar e levar para o backend
Descubra se o segredo do OAuth vazou no JavaScript, mova a troca do código para o servidor com state conferido e troque o segredo sem derrubar o login.
O login com Google do painel funciona há seis meses. Um dia alguém abre a aba Rede do navegador, clica em "Entrar" e vê um POST para o endpoint de token do Google com client_secret no corpo, saindo do próprio navegador. O guia de "aplicativo de servidor web" foi seguido, só que dentro do React.
A resposta curta: se o client secret aparece em qualquer arquivo que o navegador baixa, ele é público. A troca do authorization code pelo token tem que acontecer no backend, que lê o segredo de variável de ambiente, confere o state e devolve ao navegador só um cookie de sessão. Depois de mover, gere um segredo novo, porque o antigo já está em cache, em proxy e em histórico de build.
Por que o segredo no front é público
Tudo que vai para o bundle é texto que o navegador recebe. Minificar não esconde: a string continua lá, inteira. Variáveis de build como VITE_, NEXT_PUBLIC_ ou REACT_APP_ existem justamente para serem injetadas no JavaScript entregue ao cliente. Colocar o secret numa delas é publicá-lo.
Com o segredo em mãos, alguém pode trocar codes em nome do seu client, montar uma página de phishing que se apresenta como o seu app na tela de consentimento e gastar a sua cota. O fluxo de servidor web parte do princípio de que existe um lugar que o usuário não enxerga. Se esse lugar é o navegador, o modelo inteiro cai.
Se a sua aplicação é uma SPA sem backend próprio, o caminho é outro: cliente público com PKCE e sem secret, como em login no Vue com Authorization Code e PKCE. Este guia é para quem tem servidor e implementou o fluxo no lugar errado. O conceito geral está em OAuth no servidor web; aqui o foco é o diagnóstico e a migração.
Como saber se o seu segredo vazou
Gere o build de produção e procure o valor nos arquivos de saída. Não procure o nome da variável, procure o conteúdo: é ele que importa.
npm run build
# troque pelo início do seu secret real; nunca cole o valor inteiro em histórico compartilhado
grep -rl "INICIO_DO_SEGREDO" dist/ build/ .next/ 2>/dev/null
Se aparecer qualquer arquivo, vazou. Confira também o código-fonte por chamadas diretas ao endpoint de token (oauth2.googleapis.com/token) feitas por fetch no front, e o histórico do Git, porque o segredo pode ter saído do código e continuar nos commits antigos. Para isso, leia segredo no Git: troque a chave antes de limpar o histórico.
Como mover a troca do código para o backend
O exemplo: o painel da Loja Exemplo, com front em React e backend em Node.js com Express, atendendo em https://painel.lojaexemplo.com.br.
- Cadastre o redirect do backend no console do Google:
https://painel.lojaexemplo.com.br/auth/google/callback. HTTPS, caminho exato, sem barra a mais. - Tire o secret do front e coloque em variável de ambiente do servidor (
GOOGLE_CLIENT_SECRET), nunca com prefixo público. - Crie a rota de início, que gera um
statealeatório, guarda na sessão e redireciona para o Google. - Crie a rota de callback, que confere o
state, troca o code no servidor e cria a sessão. - Mude o botão do front para um link simples para
/auth/google. O front não vê code nem token. - Gere um novo secret no console, publique no servidor e só então desative o antigo.
O backend em Express
import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';
const app = express();
app.set('trust proxy', 1);
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 }
}));
const REDIRECT_URI = 'https://painel.lojaexemplo.com.br/auth/google/callback';
app.get('/auth/google', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state
});
res.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + params);
});
app.get('/auth/google/callback', async (req, res) => {
const { code, state, error } = req.query;
const esperado = req.session.oauthState;
delete req.session.oauthState;
if (error) return res.redirect('/login?erro=cancelado');
if (!code || !state || !esperado || state !== esperado) {
return res.status(400).send('Requisição de login inválida.');
}
try {
const resposta = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code'
}),
signal: AbortSignal.timeout(10000)
});
if (!resposta.ok) {
console.error('Troca de code falhou', resposta.status);
return res.redirect('/login?erro=falha');
}
const tokens = await resposta.json();
const usuario = await validarIdToken(tokens.id_token);
req.session.regenerate((err) => {
if (err) return res.redirect('/login?erro=falha');
req.session.usuarioId = usuario.sub;
res.redirect('/');
});
} catch (err) {
console.error('Erro no callback do Google', err.name);
res.redirect('/login?erro=falha');
}
});
Pontos que não se negocia: o state é usado uma vez e apagado; a sessão é regenerada depois do login, para evitar fixação de sessão; o log registra o status, nunca o corpo da resposta, que tem os tokens. A função validarIdToken precisa conferir assinatura, emissor, audiência e expiração com uma biblioteca oficial; o passo a passo está em conferir o token do Google no servidor.
O que devolver ao navegador
Só o cookie de sessão, httpOnly e secure. Se você precisa chamar APIs do Google em nome da pessoa, o access token e o refresh token ficam no servidor, associados à sessão, e o front pede ao seu backend. Devolver o refresh token ao JavaScript recria o problema que você acabou de resolver. Os detalhes do cookie estão em cookie HttpOnly.
Trocar o segredo sem derrubar o login
O console do Google permite gerenciar os segredos de um client. Gere o novo, atualize a variável no servidor, faça o deploy, confirme que logins novos funcionam e só então desative o antigo. Na ordem inversa, você derruba o login entre um passo e outro. O padrão geral está em trocar segredo sem derrubar o sistema.
Erros comuns
- Esconder o secret em variável com prefixo público. O build injeta o valor no JavaScript e qualquer pessoa lê na aba Fontes.
- Mover a troca para o backend e não trocar o segredo. O valor antigo continua válido e já está em caches, CDNs e históricos de build.
- Não conferir o
state. Um atacante pode fazer a vítima concluir o login com a conta dele, e os dados dela vão parar numa conta que ele controla. - Usar redirect com curinga ou HTTP. O code pode ser entregue a uma URL que você não controla ou trafegar em claro.
- Reaproveitar o client do app móvel. Os tipos de client têm regras diferentes; misturar faz um vazamento num lado comprometer o outro.
- Logar a resposta inteira do endpoint de token. O log passa a guardar access, refresh e ID token de todo mundo que entrou.
Checklist
- O
greppelo início do secret no build de produção não encontra nada. - Nenhum
fetchdo front chama o endpoint de token. - O secret vive só em variável de ambiente ou cofre do servidor.
- O redirect cadastrado é HTTPS e aponta para o backend.
- O callback rejeita requisição sem
stateou comstatediferente. - O navegador recebe só cookie de sessão
httpOnly, sem tokens no corpo. - O segredo antigo foi desativado depois do deploy do novo.
Rode agora o build de produção do seu front e procure o início do seu client secret nos arquivos gerados: ele aparece em algum arquivo que o navegador baixa?
Referência: https://developers.google.com/identity/protocols/oauth2/web-server?hl=pt-br