Autenticando um servidor MCP remoto quando o cliente não envia header
Claude Desktop e Cursor mandam um header Authorization. O painel de Connectors do Claude.ai na web só deixa colar uma URL. Aqui está o fallback de chave via query string e como deixar uma credencial dentro de uma URL segura o suficiente pra ir pra produção.
por Zechim
A gente roda um servidor MCP público em zechim.com/mcp. Ele expõe três ferramentas somente-leitura sobre um dataset de e-commerce fake (a Acme Store do nosso demo): list_tables, describe_table, e uma ferramenta query que roda SQL apenas de SELECT. Qualquer pessoa com uma API key aponta um cliente MCP pra lá e faz perguntas pros dados.
A parte interessante não foram as ferramentas. Foi a autenticação.
O caminho limpo: um header Bearer
A maioria dos clientes MCP deixa você configurar headers customizados. Claude Desktop, Cursor, Cline e Claude Code leem um arquivo de config onde dá pra anexar um header Authorization num servidor remoto:
{
"mcpServers": {
"zechim-demo": {
"url": "https://zechim.com/mcp",
"headers": { "Authorization": "Bearer zk_live_..." }
}
}
}
Então a checagem no servidor é a óbvia. Parsear o token bearer, fazer o hash, procurar no banco, checar a quota:
function parseBearer(header: string | null): string | null {
if (!header) return null;
const m = header.match(/^Bearer\s+(.+)$/i);
return m ? m[1].trim() : null;
}
A gente faz o hash da chave apresentada com SHA-256 e procura pelo hash. A chave em texto puro (zk_live_<43 chars base64url>) aparece pro usuário exatamente uma vez, na emissão, e nunca é armazenada. Um banco vazado entrega hashes pro atacante, não chaves.
Isso funcionava pros clientes desktop. Aí alguém tentou conectar pelo Claude.ai na web.
A parede: não existe campo de header
O painel de Connectors do Claude.ai na web só deixa colar uma URL. Não tem campo pra header customizado. O mesmo vale pra algumas outras UIs MCP mais simples: a credencial inteira precisa viver na URL, ou você simplesmente não conecta.
Você tem duas opções. Recusar esses clientes, ou aceitar a chave na URL. A gente aceita.
function parseQueryKey(req: Request): string | null {
try {
const url = new URL(req.url);
// Aceita ?key= e o mais verboso ?api_key= (UIs MCP usam um ou
// outro no template). Trim defensivo de espaços.
const raw =
url.searchParams.get("key") ?? url.searchParams.get("api_key");
if (!raw) return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
A URL de conexão vira https://zechim.com/mcp?key=zk_live_... e isso é a configuração inteira. O header tem precedência quando os dois estão presentes, então um cliente desktop que manda header não é afetado:
const header = req.headers.get("authorization");
const raw = parseBearer(header) ?? parseQueryKey(req);
Mesmo formato de chave, mesma busca, mesma contabilidade de quota. O caminho 2 e o caminho 3 são idênticos depois do parse.
"Mas credencial dentro de URL é perigoso"
É mesmo. Essa objeção está correta, e vale ser preciso sobre o porquê, porque a correção não é "não faça isso".
Um segredo numa query string vaza por canais que um header não vaza:
- Cai nos logs de acesso (os seus, e os de qualquer proxy ou CDN na sua frente).
- Aparece no histórico do navegador e no header
Refererde links de saída. - Acaba colado em chat e em ticket como parte da URL inteira.
Então uma chave que viaja numa URL é uma credencial de menos confiança do que uma que vai no header. O erro é tratar isso como uma pergunta de sim ou não. A pergunta de verdade é: qual é o raio de explosão se essa chave específica vazar? Projete a credencial pra que a resposta seja "pequeno", e a query string deixa de assustar.
Quatro coisas tornam a nossa aceitável:
1. A chave é somente-leitura. A ferramenta query rejeita no servidor qualquer coisa que não seja SELECT ou WITH. INSERT, UPDATE, DELETE e DDL nunca rodam. Uma chave vazada não consegue alterar dados, só ler um dataset que já é dado de demo público.
2. A chave é revogável e fica em hash. A gente guarda SHA-256 do texto puro, nunca o texto puro. Revogar é virar uma coluna, e vale a partir do próximo request. Se uma chave aparece onde não devia, você mata em um passo e emite outra.
3. A chave tem quota diária. Cada chave carrega uma daily_quota. A checagem roda antes de incrementar o contador, então uma chave nova com quota 100 permite exatamente 100 chamadas com sucesso, e depois devolve 429 até 00:00 UTC. Uma chave vazada não roda sem limite; no pior caso queima um dia do orçamento de outra pessoa.
4. Tem um portão de burst por IP na frente do banco. Antes de a gente tocar no Supabase pra validar uma chave, um balde anônimo permite 60 requests por IP por minuto. Isso não é sobre o usuário legítimo. É pra impedir que uma enxurrada de sondas ?key=lixo martele a busca de autenticação a cada request.
Juntando tudo, a credencial na URL é limitada em escopo (somente-leitura), limitada no tempo (revogável), limitada em volume (quota diária) e blindada contra enxurradas (portão de burst). Um vazamento vira um não-evento: revoga, reemite, segue a vida.
A ordem das checagens importa
O wrapper em volta do handler MCP roda tudo numa ordem deliberada: o mais barato e mais protetivo primeiro.
async function withAuth(req: Request): Promise<Response> {
// 1. Bypass interno: o nosso próprio /api/chat conecta in-process com
// um header de segredo do servidor. Pula o portão de burst público
// pra que o loop do agente do site não dispute o orçamento de 60/min.
const isInternalBypass =
Boolean(internalKey) &&
req.headers.get("x-internal-key") === internalKey;
// 2. Portão de burst por IP, ANTES de qualquer chamada ao Supabase.
if (!isInternalBypass) {
const rl = await checkMcpAnonymousLimit(req);
if (!rl.allowed) return rateLimitJson(rl);
}
// 3. Valida a chave (header ou query string), checa a quota diária.
const auth = await authorizeMcpRequest(req);
if ("status" in auth) return jsonRpcError(auth.status, auth.reason);
// 4. Segue pras ferramentas de verdade.
return handler(req);
}
O portão de burst fica antes da busca de autenticação de propósito. Autenticar toca no Postgres; o rate limit não pode deixar tráfego não autenticado decidir com que frequência o Postgres é tocado. O bypass interno existe porque o chat do próprio site conecta no mesmo servidor MCP a cada turno, e ele não deve disputar com usuários externos o orçamento público nem queimar a quota de um visitante.
Mais um detalhe que vale copiar: o incremento da quota é fire-and-forget. Se a contagem falhar, a gente ainda serve o request. O pior caso é uma chamada não contabilizada, o que é muito melhor do que negar um usuário pagante porque uma escrita na tabela de uso engasgou.
A lição
Encontre o cliente onde ele está. Um header é mais limpo, mas se um cliente real só consegue colar uma URL, recusá-lo é um resultado pior do que aceitar um canal um pouco mais fraco, desde que você faça o trabalho de tornar esse canal seguro.
A query string não é o problema. Uma chave sem limite, de vida longa e com poder de escrita é o problema, e essa chave é perigosa num header também. Limite ela a somente-leitura, dê uma quota, torne ela revogável e coloque um portão de burst na frente do seu banco. Aí a pergunta de header-versus-query-string vira um detalhe de UX em vez de um incidente de segurança.
Esse é o mesmo servidor MCP por trás do nosso /demo, e o mesmo modelo de autenticação que a gente reaproveita no portfólio: zeobra.com.br, repasse.co e manufa.com.br cada um expõe dados pra agentes, e cada um tem que responder "o que acontece se essa credencial vazar?" antes de ir pra produção.
Se você está construindo um servidor MCP, uma API pública, ou qualquer superfície onde um agente autentica em nome de um usuário, e quer um segundo par de olhos no modelo de credencial antes de ir pra produção, vamos conversar 30 minutos.