Você faz POST de uma query string para o servidor, e no log de debug do servidor você vê q=hello%20world%26foo%3Dbar. Sua primeira reação provavelmente é: «o middleware do servidor foi desconfigurado? tem algo errado no parser?». A resposta é, vergonhosamente, mais simples que isso: isso é a codificação de URL da RFC 3986 (também chamada codificação por percentual) substituindo caracteres automaticamente antes do request sair do navegador, e o servidor não tem nada a ver com isso.

A codificação por percentual é, no fundo, isto: pegar qualquer caractere que seja inseguro ou ambíguo dentro de uma URL e substituir pela forma «percentual + dois bytes hexadecimais». Então um espaço vira %20, um & vira %26, um = vira %3D. Assim, URLs que viajam entre sistemas, protocolos e conjuntos de caracteres não quebram porque sistemas diferentes interpretam o mesmo byte de forma diferente.

Mas a RFC 3986 só define o que a codificação «faz». NÃO define quem codifica, quando, nem quantas vezes. Essas três decisões são tomadas de forma independente pelos navegadores, frameworks de servidor e bibliotecas cliente, e os 8 bugs comuns nascem todos desse vazio.

Este post percorre os 8 foot-guns mais comuns de URL encoding, cada um em uma estrutura de três tempos «sintoma → por quê → fix», e termina com uma ferramenta recomendada que roda local no navegador, Piick URL Encoder / Decoder, que vem com 3 modos (cada um retorna a saída correta para seu domínio), detecta automaticamente a dupla codificação e mostra chips de aviso para você poder sanity-checar seu round-trip em menos de um minuto.

Resumo em 30 segundos

  • URL encoding (codificação por percentual) é o padrão RFC 3986 para substituir caracteres inseguros ou ambíguos dentro de uma URL com a forma %XX (onde XX é o valor hex do byte)
  • O kit de JavaScript tem três peças: encodeURIComponent (nível de componente, o default seguro), encodeURI (URL inteira mas preserva /?&=# caracteres sintáticos de URL — perigoso), e URLSearchParams (apenas query strings)
  • Os 8 foot-guns comuns: dupla codificação, mais vs %20, caracteres reservados sem codificar, ponto e vírgula dentro de segmentos de path (legal mas ambíguo), Unicode não pré-codificado, OAuth state fazendo round-trip por múltiplos saltos, application/x-www-form-urlencoded misturado com Content-Type application/json, comportamento de decodificação padrão do servidor varia por linguagem
  • A direção do fix é sempre a mesma: codifique exatamente uma vez com encodeURIComponent(value), codifique exatamente uma vez ao longo de toda a cadeia, e nunca recodifique algo que o salto anterior já codificou
  • Use Piick URL Encoder / Decoder para codificar ou decodificar online, obtenha a saída correta para cada um dos 3 modos e mantenha seus dados locais. URLs de callback sensíveis e tokens de API nunca saem do navegador

Os 8 foot-guns comuns

Foot-gun 1: Dupla codificação (o %2520 não é uma falha de decodificação)

Sintoma: você recebe um string como %2520, roda decodeURIComponent uma vez e obtém %20, roda uma segunda vez e obtém o verdadeiro (espaço). Seu primeiro pensamento é «onde decodifiquei uma vez a mais?».

Por que acontece: um espaço codifica para %20 (quatro caracteres: percentual, dois, zero). Esses quatro caracteres então são tratados como quatro caracteres ordinários %, 2, 0 e «codificados de novo» por algum framework ou por você, produzindo %2520. Cada salto que codifica adiciona outra camada; se ao menos um salto codifica redundante um valor que já estava codificado, você obtém dupla codificação.

Fontes comuns: framework de servidor que trata a URL inteira como string plana e codifica de novo; ou URLSearchParams do frontend que auto-codifica em cima de encodeURIComponent escrito à mão; ou redirects OAuth onde cada redirect recodifica. Os três são o mesmo bug em escalas diferentes.

Fix: codifique exatamente uma vez ao longo de toda a cadeia. Regra prática: o cliente (navegador) faz a codificação, o servidor faz a decodificação — e nunca o contrário. Cole sua URL no modo Full URL de Piick e ela detecta automaticamente a dupla codificação mostrando um chip de aviso, sem precisar contar %25 a olho.

Cenário real: uma callback de OAuth2 chega com state=abc%2525xyz, o servidor decodifica uma vez e obtém abc%25xyz, a camada de negócio compara com o original e os valores não batem, o fluxo termina com state mismatch.

Foot-gun 2: Mais vs %20 (a bifurcação histórica do form encoding)

Sintoma: o body de um POST de formulário tem q=hello+world e o servidor diz «leio q como hello world, por que tem um +?». Ou ao contrário: o path de uma URL é path=/hello world e o servidor lê path como /hello world (espaço não codificado).

Por que acontece: quando se envia um formulário HTML, se o método do formulário é GET ou o enctype é application/x-www-form-urlencoded, o navegador codifica os espaços nos campos do formulário como + (NÃO %20). É uma convenção herdada do HTML 4 que se aplica apenas a bodies de formulário e às query strings de envios GET de formulário. Em paths de URL e componentes de URL, espaços sempre codificam como %20. URLSearchParams é uma terceira semântica ainda (trata + como caractere literal, não como espaço). JS tem três regras, e misturá-las é a forma mais fácil de colocar um bug em produção.

Fix:

  • Os paths de URL e os valores de query parameter usam modo Component de Piick — sempre %20
  • Os bodies de formulário e os envios GET de formulário usam modo Query de Piick+%20 automático
  • Não misture os três conjuntos de regras, nem dentro da mesma app

Cenário real: você escreve fetch('/api?q=' + userInput) onde userInput é hello world. A URL final é /api?q=hello world (espaço não codificado). O router do lado servidor encontra um espaço, dispara URIError: URI malformed, toda a request morre.

Foot-gun 3: Caracteres reservados sem codificar, o servidor divide campos errado

Sintoma: você monta ?q=foo&bar=baz pensando que vai obter um parâmetro q=foo&bar=baz. O servidor realmente recebe dois parâmetros, q=foo e bar=baz, e o segundo sobrescreve o primeiro.

Por que acontece: o parser de URL vê & como delimitador entre parâmetros não importa o quê. Então q=foo&bar=baz são dois parâmetros independentes. Para tratar & como caractere literal dentro do valor, você precisa codificar com encodeURIComponent, que te dá %26.

Fix: sempre processe valores com encodeURIComponent(value). Não faça concatenação crua de strings, não use encodeURI (que preserva &), não pule a codificação.

Cenário real: um cliente GraphQL concatena uma query na URL — a query contém uma query GraphQL com chaves e parâmetros. Sem codificar, a URL trunca em abc, o parser GraphQL do backend dispara erro de sintaxe. 99 % desses bugs são este.

Foot-gun 4: Ponto e vírgula e barras em segmentos de path, ambiguidade entre linguagens

Sintoma: um design de path de URL RESTful /users/john;doe. O backend Spring do Java trata ;doe como parâmetro matrix. Express do Node.js trata tudo como um segmento plano. Python Flask faz outra coisa. A mesma URL, três linguagens, três resultados diferentes.

Por que acontece: a RFC 3986 lista ; sob sub-delims, legal dentro de paths, mas a semântica é ambígua — a RFC define como semântica OPCIONAL de parâmetro matrix. / é o delimitador de segmentos, mas uma vez codificado (virando %2F) frameworks diferentes seguem convenções diferentes. Múltiplos comportamentos, sem consenso.

Fix: use encodeURIComponent em cada valor dentro de cada segmento de path (não codifique o segmento inteiro, apenas os valores). Se você precisar usar parâmetros matrix, trave em um único framework e documente; não pule entre linguagens.

Cenário real: os paths iniciais da API do Twitter continham ponto e vírgula (/statuses/show/:id.json;count=10). Os clientes Python e o SDK oficial Java parseavam esses paths de forma diferente, e engenheiros cross-linguagem tinham uma tarefa diária de debug.

Foot-gun 5: Unicode não pré-codificado, UTF-8 / GBK do servidor se enrola

Sintoma: uma URL contém chinês como «用户搜索». O frontend envia a request diretamente. O Nginx do servidor mais o backend retornam «a query vem ilegível» ou URIError: URI malformed.

Por que acontece: encodeURIComponent("用户") produz %E7%94%A8%E6%88%B7 (sequência de bytes UTF-8). Concatenar diretamente caracteres não ASCII em uma URL é uma violação da RFC — a RFC define um subconjunto ASCII mais uma extensão de escape por percentual mas não especifica como caracteres não ASCII são transmitidos. Mesmo quando o Nginx está configurado com charset utf-8, ele trata URLs a nível de bytes e não adivinha conjuntos de caracteres por você.

Fix:

  • Frontend: sempre encodeURIComponent(value) antes de concatenar. Produz sequências UTF-8 naturalmente.
  • Lado servidor: NÃO tente «auto-detectar conjunto de caracteres». Estabeleça que clientes devem codificar primeiro.
  • Enquanto testa: abra o DevTools, olhe a aba Network. Se a request line já é %E7%94..., você está bem.

Cenário real: um usuário overseas busca «手机» em um site de e-commerce chinês. O frontend não codifica, o servidor tenta decodificar GBK e obtém lixo, os resultados de busca não conferem com a busca, a taxa de conversão cai pela metade.

Foot-gun 6: state de OAuth fazendo round-trip por múltiplos saltos, defesa CSRF quebrada

Sintoma: a callback do OAuth 2.0 chega com state. Localmente você codifica e passa para o servidor. O servidor codifica de novo ou codifica implicitamente. Quando o redirect chega ao endpoint final, state não bate com o valor original e o fluxo termina.

Por que acontece: o parâmetro state de OAuth foi projetado para ser a identidade round-trip da request originária (defesa CSRF). Ele tem que viajar gerar local → codificar → URL → cruzar rede → servidor → decodificar → comparar. Bibliotecas OAuth diferentes tratam os defaults de encode/decode de forma diferente (Auth0 codifica por default, NextAuth não, Spring Security faz outra coisa), e colaborações cross-linguagem cross-biblioteca se quebram.

Fix:

  • Uma vez que você se compromete com uma biblioteca OAuth, use a convenção de codificação que ela recomenda. NÃO empilhe encodeURIComponent manual em cima.
  • Auto-teste: codifique local → construa URL → servidor decodifica → compare → deve bater
  • Use o modo Query de Piick URL Encoder / Decoder para comparar raw e decoded em ambas as extremidades

Cenário real: quando se integra Notion ou Google OAuth, os bugs de mismatch do state por default aparecem 3-5 vezes em uma semana. A causa comum é o desenvolvedor adicionar seu próprio encodeURIComponent em cima do que a biblioteca já faz.

Foot-gun 7: application/x-www-form-urlencoded misturado com Content-Type JSON

Sintoma: você diz «meu body de POST é JSON» mas fetch adiciona Content-Type: application/x-www-form-urlencoded (ou ao contrário, o body está em formato urlencoded mas o Content-Type diz JSON), e o servidor rejeita ou parseia errado.

Por que acontece: o Content-Type application/x-www-form-urlencoded força o body a passar pelo parser urlencoded, que trata cada chave de abertura, chave de fechamento, dois pontos e aspas no body como caractere ilegal ou literal. Ao contrário, o parser de application/json espera que o body seja JSON válido, e ao ver formato urlencoded dispara SyntaxError.

Fix: o Content-Type deve casar com o formato real do body. JSON usa application/json e o body é JSON real. Formulários usam application/x-www-form-urlencoded e o body é key=value&key2=value2.

Cenário real: armadilha clássica de debug de webhook — Stripe envia um body JSON de webhook, você copia o comando curl e muda o Content-Type para urlencoded, o servidor parseia o string JSON como parâmetros literais, todo campo volta null.

Foot-gun 8: O comportamento de decodificação padrão do servidor é inconsistente entre linguagens

Sintoma: seu código decodeURIComponent(req.url) explode no Node.js Express com URIError: URI malformed. Você acha que o servidor tem bug, mas o servidor já decodificou antes de você chegar.

Por que acontece:

  • Node.js / Nginx / Apache / Go net/http / Spring / vários frameworks de servidor tomam decisões diferentes sobre pré-decodificar URLs
  • A convenção usual: o path já foi decodificado uma vez, o query já foi decodificado uma vez (implícito)
  • Express NÃO decodifica por default. Então quando você escreve decodeURIComponent, req.url já foi decodificado uma vez e você pega URIError.
  • Mas req.originalUrl mostra raw, você não consegue comparar diretamente.

Fix:

  • Leia os docs oficiais, descubra o comportamento de decodificação padrão do framework
  • Use o modo Component de Piick: uma codificação, uma decodificação, sem dobro no meio
  • Quando o stack está em camadas (Express + Nginx + URL rewriter), olhe o access.log e o debug.log procurando a posição do %20 — isso aponta qual camada introduziu uma codificação extra

Cenário real: um app de e-commerce com três camadas (Nginx + Express + ORM) recebe reports de bug «parâmetros de request às vezes se parseiam errado». A causa raiz é que o framework ORM decodifica uma vez internamente, Nginx decodifica uma vez, Express decodifica de novo — três decodificações totais, os dados estão embaraçados.

Decisões de seleção de ferramenta

Três rotas principais para codificação/decodificação de URL, cada uma cabe em um cenário diferente:

Ferramenta online no navegador (este blog / esta ferramenta)

Pontos fortes: zero instalação, zero upload, amigável para URLs de callback sensíveis e tokens de API. Três modos (Component / Query / Full URL) auto-determinam as regras de codificação corretas, a auto-detecção de dupla codificação mostra chips de aviso. Cabe em debug de uma vez, triage de incidente em produção, troubleshooting de state de OAuth. Piick URL Encoder / Decoder é essa rota — teste.

Funções nativas de Node.js / navegador (no seu código)

  • encodeURIComponent(str) + decodeURIComponent(str) — nível de componente, o default diário
  • encodeURI(str) + decodeURI(str) — URL inteira, preserva /?&=# caracteres sintáticos de URL. A menos que você saiba exatamente o que está fazendo, não use isso.
  • new URL(str) — parseia uma URL inteira, muta partes e depois .toString()
  • URLSearchParams — apenas query strings. NÃO trata + raw como espaço (o inverso de form encoding)

Cabe em pipelines de build, testes unitários, automação. O rodapé da página da ferramenta Piick tem a tabela completa de regras da RFC 3986 para cross-referência.

Middleware do framework do servidor (Express / Spring / Rails integrado)

O framework lida com o round-trip rotineiro para você não ter que se preocupar. Desvantagem: casos de borda (query com ;, espaços, Unicode) o framework não consegue ajudar; você ainda precisa de um decodeURIComponent manual de fallback. Cabe em apps web CRUD, não recomendado em cenários de alta segurança ou alta complexidade.

Recomendação de workflow cross-tool: debug com uma ferramenta de navegador → escreva código com encodeURIComponent → rode testes unitários de round-trip em CI → antes do webhook signing, passe a URL canônica pelo modo Full URL de Piick, depois encadeie no validador de assinatura de webhook para conferir que as assinaturas batem.

5 cenários do mundo real

Cenário 1: Mismatch de state em callback de OAuth2

Anti-padrão: codifica local, depois o servidor codifica de novo automaticamente, o round-trip falha. Fix: trave no comportamento de encoding da biblioteca OAuth, não empilhe encoding manual em cima. Verifique o round-trip comparando o resultado decodificado da extremidade local contra o resultado decodificado do servidor no modo Query de Piick.

Cenário 2: URL de webhook com query, normaliza antes de assinar

Preparar uma assinatura de webhook (Stripe / GitHub / Slack e outros) tipicamente usa um string canônico: normalize a URL (strip host, ordene as keys do query, URL encode, depois body hash, finalmente HMAC com o secret). Nessa cadeia, a codificação de URL acontece exatamente uma vez, no passo de string canônico. O modo Full URL de Piick te ajuda a ver exatamente como fica o string codificado. Depois de assinar, verifique com o validador de assinatura de webhook para confirmar que a assinatura bate.

Cenário 3: Você vê %20 nos logs mas é um tema de configuração

Quando o access.log do Nginx mostra strings duplamente codificados como %2520 e %2526, a causa geralmente é um salto na cadeia de redirects codificando redundante. Localize-o com grep + decodificação reversa:

Por exemplo, faça grep no access log por todas as entradas que contenham %25, depois faça grep na cadeia de redirect dessa [URL] específica (as primeiras entradas) para ver em que salto %2520 aparece primeiro — esse é o salto com o bug.

Em que salto %2520 aparece primeiro? Esse é o salto com o bug.

Cenário 4: O frontend monta URL de busca sem codificar, o servidor divide campos errado

Anti-padrão: fetch constrói a URL de busca usando concatenação de template de string onde userInput é foo & bar. A URL fica /api/search?q=foo & bar, o espaço não está codificado, & é tratado como separador de parâmetros. Fix: envolva com encodeURIComponent(userInput). Essa é a causa raiz mais comum de tickets de «por que meu query parameter está errado».

Cenário 5: Path de URL REST contém caracteres especiais

Anti-padrão: GET /api/users/john doe (com espaço). O backend devolve 404. Fix: encodeURIComponent('john doe')john%20doe, a URL final é /api/users/john%20doe, o backend recebe «john doe».

Práticas recomendadas

  • Sempre encodeURIComponent(value) para os valores. Nunca encodeURI, nunca pule a codificação.
  • Codifique exatamente uma vez ao longo de toda a cadeia: o cliente (navegador) faz a codificação, o servidor faz a decodificação, nunca o contrário. Em cenários multi-salto como OAuth ou Webhooks, se cada salto recodifica você obtém dupla codificação. A regra é: codifique exatamente uma vez ao longo de toda a cadeia.
  • Teste de round-trip antes de produção: cole seu valor original no Piick, olhe a saída codificada, cole essa saída no ambiente de teste, decodifique e verifique que você obtém o mesmo valor. Esse sanity check leva 5 segundos.
  • Não misture os três conjuntos de regras: componentes de URL usam modo Component, query strings usam modo Query, bodies de formulário usam urlencoded. Não use um conjunto do lado JS e outro do lado Python.
  • URLs de callback sensíveis e tokens de API nunca saem do navegador: use Piick URL Encoder / Decoder online, 100 % dados locais, nada enviado.

URL encoding é infraestrutura inevitável embutida no protocolo HTTP, mas a RFC 3986 te dá o «quê» — o «como» é decidido pelo seu stack. Depois de debugar o suficiente, você vai conhecer as armadilhas de cor, mas até lá, marque nos favoritos Piick URL Encoder / Decoder, abra para um sanity check de 1 minuto quando algo parecer errado, não perca tempo.