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), eURLSearchParams(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, OAuthstatefazendo round-trip por múltiplos saltos,application/x-www-form-urlencodedmisturado com Content-Typeapplication/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 —
+↔%20automá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
encodeURIComponentmanual 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.urljá foi decodificado uma vez e você pegaURIError. - Mas
req.originalUrlmostra 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árioencodeURI(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') dá john%20doe, a URL final é /api/users/john%20doe, o backend recebe «john doe».
Práticas recomendadas
- Sempre
encodeURIComponent(value)para os valores. NuncaencodeURI, 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.