Codificação de URL: como funciona o percent-encoding

A codificação de URL troca cada byte que não pode aparecer como está em um endereço por um sinal de % seguido do seu valor em hexadecimal.

Uma URL só pode conter um pequeno conjunto de caracteres ASCII, e vários deles têm uma função precisa: / separa os segmentos do caminho, ? abre a query string, & separa os parâmetros e # marca o início do fragmento. Assim que um dado contém um espaço, uma letra acentuada ou um desses caracteres, é preciso codificá-lo. A codificação de URL, também chamada de codificação percentual ou percent-encoding, é definida pela RFC 3986. Na prática, ela diz ao servidor: "este caractere faz parte do valor, não é um separador".

O princípio: um byte vira três caracteres

Um caractere codificado é escrito como % seguido de dois dígitos hexadecimais, que dão o valor de um byte (RFC 3986, seção 2.1). O espaço tem código 32, ou 20 em hexadecimal, e por isso vira %20. Para um texto qualquer, a codificação acontece em três etapas:

  1. Converter o texto em bytes UTF-8.
  2. Manter como estão os bytes que correspondem a caracteres não reservados (letras ASCII, dígitos e - . _ ~).
  3. Trocar cada um dos outros bytes por % e o seu valor hexadecimal, de preferência em maiúsculas.

Um caractere fora do ASCII ocupa vários bytes em UTF-8, e é por isso que uma única letra gera vários grupos percentuais:

CaractereBytes UTF-8Forma codificada
espaço20%20 (ou + em um formulário)
&26%26
=3D%3D
+2B%2B
éC3 A9%C3%A9
€E2 82 AC%E2%82%AC
😀F0 9F 98 80%F0%9F%98%80

Assim, "café & thé" vira caf%C3%A9%20%26%20th%C3%A9. A escolha do UTF-8 não é por acaso: a RFC 3986 a recomenda (seção 2.5), e é o que usam os navegadores e as linguagens modernas. Sistemas antigos codificavam o texto em Latin-1, em que é cabe em um único byte e vira %E9. Um decodificador UTF-8 recusa essa sequência: em JavaScript, decodeURIComponent('%E9') lança URIError: URI malformed (URI malformada).

Caracteres reservados e não reservados

A RFC 3986 divide os caracteres em dois grupos:

  • Não reservados (seção 2.3): A-Z a-z 0-9 - . _ ~. Nunca precisam ser codificados, e a norma pede que não sejam: %41 e A significam a mesma coisa.
  • Reservados (seção 2.2): os delimitadores gerais : / ? # [ ] @ e os subdelimitadores ! $ & ' ( ) * + , ; =. São eles que estruturam a URL.

A regra cabe em uma frase: um caractere reservado é escrito como está quando cumpre seu papel de separador, e codificado quando faz parte de um dado. Compare:

https://exemple.fr/recherche?q=pain&lait      -> q = "pain", mais um parâmetro "lait" vazio
https://exemple.fr/recherche?q=pain%26lait    -> q = "pain&lait"

Todos os outros caracteres, como espaços, aspas, o próprio %, acentos e emojis, precisam ser codificados em qualquer posição. O sinal % se escreve, portanto, %25: o texto "100 %", com espaço antes do sinal, vira 100%20%25.

O espaço: %20 ou +?

Segundo a RFC 3986, um espaço é codificado como %20. Mas os formulários HTML usam outro formato, application/x-www-form-urlencoded, em que o espaço vira + e um sinal de mais de verdade precisa ser escrito %2B. Os navegadores usam esse formato nos formulários enviados por GET, e a maioria dos servidores decodifica as query strings dessa maneira.

Daí vem uma confusão frequente. Em um caminho, + é um simples sinal de mais. Em uma query string, ele costuma ser lido como espaço: uma busca por "C++" enviada sem codificação, ?q=C++, chega ao servidor como "C" seguido de dois espaços. Codificada corretamente, ela fica ?q=C%2B%2B.

LinguagemEspaço como %20 (RFC 3986)Espaço como + (formulários)
JavaScriptencodeURIComponent()URLSearchParams
PHPrawurlencode()urlencode(), http_build_query()
Pythonurllib.parse.quote()quote_plus(), urlencode()

As duas formas são corretas em uma query string, desde que o lado que decodifica aplique a mesma convenção do lado que codifica.

encodeURI ou encodeURIComponent

O JavaScript oferece duas funções que diferem nos caracteres que deixam intactos. encodeURIComponent codifica tudo, exceto A-Z a-z 0-9 - _ . ! ~ * ' ( ): é a função para usar com um valor, seja um parâmetro ou um segmento de caminho. encodeURI deixa passar também # $ & + , / : ; = ? @, porque parte do princípio de que recebeu uma URL completa com a estrutura já correta. Aplicada a um endereço montado à mão, ela deixa passar os separadores que estão dentro dos dados:

encodeURI('https://exemple.fr/recherche?q=café & thé');
// 'https://exemple.fr/recherche?q=caf%C3%A9%20&%20th%C3%A9'
// o & não é codificado: o valor é cortado em dois

'https://exemple.fr/recherche?q=' + encodeURIComponent('café & thé');
// 'https://exemple.fr/recherche?q=caf%C3%A9%20%26%20th%C3%A9'

O mais seguro continua sendo deixar a API URL codificar cada parte no lugar certo:

const url = new URL('https://exemple.fr/recherche');
url.searchParams.set('q', 'café & thé');
url.searchParams.set('tri', 'prix');
url.href;  // 'https://exemple.fr/recherche?q=caf%C3%A9+%26+th%C3%A9&tri=prix'

Exemplos em JavaScript, PHP e Python

Aqui está o mesmo valor codificado e decodificado nas três linguagens, com as duas convenções para o espaço:

// JavaScript
encodeURIComponent('café & thé');                 // 'caf%C3%A9%20%26%20th%C3%A9'
new URLSearchParams({ q: 'café & thé' }).toString(); // 'q=caf%C3%A9+%26+th%C3%A9'
decodeURIComponent('caf%C3%A9%20%26%20th%C3%A9'); // 'café & thé'

// PHP (arquivo salvo em UTF-8)
rawurlencode('café & thé');                // caf%C3%A9%20%26%20th%C3%A9
urlencode('café & thé');                   // caf%C3%A9+%26+th%C3%A9
http_build_query(['q' => 'café & thé']);   // q=caf%C3%A9+%26+th%C3%A9
urldecode('caf%C3%A9+%26+th%C3%A9');       // café & thé

# Python
from urllib.parse import quote, quote_plus, urlencode, unquote_plus
quote('café & thé')                    # 'caf%C3%A9%20%26%20th%C3%A9'
quote_plus('café & thé')               # 'caf%C3%A9+%26+th%C3%A9'
urlencode({'q': 'café & thé'})         # 'q=caf%C3%A9+%26+th%C3%A9'
unquote_plus('caf%C3%A9+%26+th%C3%A9') # 'café & thé'

Dois detalhes que vale conhecer. Em Python, quote() deixa a barra intacta por padrão: quote('a/b') retorna a/b. Para codificar um valor isolado, passe safe='', que dá a%2Fb. Em PHP, urlencode() também codifica o til como %7E, enquanto rawurlencode() o deixa como está, como manda a RFC 3986.

A codificação dupla

Codificar uma string já codificada transforma cada % em %25. %20 vira %2520, e caf%C3%A9 vira caf%25C3%25A9. Na convenção dos formulários, quem denuncia o problema é o +: em PHP, urlencode(urlencode('a b')) retorna a%2Bb, que o servidor decodifica como "a+b" em vez de "a b".

A causa é quase sempre a mesma: duas camadas do código codificam o mesmo valor, por exemplo uma biblioteca HTTP que codifica parâmetros que você já passou codificados. A mesma armadilha existe na decodificação: em PHP, $_GET já traz os valores decodificados, e chamar urldecode() sobre eles decodifica uma segunda vez, o que transforma, entre outras coisas, os + legítimos em espaços. A regra: codifique o valor bruto uma única vez, no momento em que ele entra na URL, e decodifique uma única vez na leitura.

Só um caso justifica uma forma que parece codificação dupla: uma URL passada como parâmetro de outra URL, por exemplo em redirect=. Se a URL interna já contém %20, a sua forma codificada contém %2520, e isso está correto: o destinatário decodifica uma vez e recupera a URL original.

Codificar e decodificar online

Para conferir um valor sem escrever código, o Codificador e Decodificador de URL oferece os dois modos, encodeURIComponent e encodeURI, para comparar o resultado de cada um no seu texto. O Decodificador de URL decodifica enquanto você digita e lê + como espaço, do jeito dos formulários: um sinal de mais de verdade precisa estar escrito como %2B. Tudo acontece no seu navegador.

Perguntas Frequentes

Por que o espaço vira %20 em uma URL?

O código do espaço é 32 em decimal, ou 20 em hexadecimal. A codificação de URL escreve um byte como % seguido dos seus dois dígitos hexadecimais, daí o %20.

É preciso codificar letras acentuadas em uma URL?

Sim. Uma letra como é é convertida em bytes UTF-8 e depois cada byte é codificado: é vira %C3%A9. O navegador muitas vezes mostra a forma decodificada na barra de endereços, mas a requisição leva a forma codificada.

Qual é a diferença entre %20 e +?

%20 é a codificação padrão do espaço segundo a RFC 3986. O + só significa espaço em dados de formulário, ou seja, na prática, em query strings; em um caminho, é um sinal de mais.

Quais caracteres nunca precisam ser codificados?

Os caracteres não reservados da RFC 3986: as letras ASCII sem acento, os dígitos, o hífen, o ponto, o sublinhado e o til. Todos os outros precisam ser codificados quando fazem parte de um dado.