Encodage URL : comment fonctionne le percent-encoding

L'encodage URL remplace chaque octet qui ne peut pas figurer tel quel dans une adresse par un signe % suivi de sa valeur en hexadécimal.

Une URL ne peut contenir qu'un petit jeu de caractères ASCII, et plusieurs d'entre eux ont un rôle précis : / sépare les segments du chemin, ? ouvre la requête, & sépare les paramètres, # annonce le fragment. Dès qu'une donnée contient une espace, une lettre accentuée ou l'un de ces caractères, il faut l'encoder. L'encodage URL, appelé aussi encodage pourcent ou percent-encoding, est défini par la RFC 3986. Il revient à dire au serveur : « ce caractère fait partie de la valeur, ce n'est pas un séparateur ».

Le principe : un octet devient trois caractères

Un caractère encodé s'écrit % suivi de deux chiffres hexadécimaux, qui donnent la valeur d'un octet (RFC 3986, section 2.1). L'espace a le code 32, soit 20 en hexadécimal : elle devient %20. Pour un texte quelconque, l'encodage se fait en trois étapes :

  1. Convertir le texte en octets UTF-8.
  2. Laisser tels quels les octets qui correspondent à des caractères non réservés (lettres ASCII, chiffres et - . _ ~).
  3. Remplacer chacun des autres octets par % et sa valeur en hexadécimal, de préférence en majuscules.

Un caractère hors ASCII occupe plusieurs octets en UTF-8, d'où plusieurs groupes pourcent pour une seule lettre :

CaractèreOctets UTF-8Forme encodée
espace20%20 (ou + dans un formulaire)
&26%26
=3D%3D
+2B%2B
éC3 A9%C3%A9
€E2 82 AC%E2%82%AC
😀F0 9F 98 80%F0%9F%98%80

Ainsi, « café & thé » devient caf%C3%A9%20%26%20th%C3%A9. Le choix de l'UTF-8 n'est pas anodin : la RFC 3986 le recommande (section 2.5), et c'est ce qu'utilisent les navigateurs et les langages modernes. D'anciens systèmes encodaient le texte en Latin-1, où é tient sur un seul octet et devient %E9. Un décodeur UTF-8 refuse cette séquence : en JavaScript, decodeURIComponent('%E9') lève URIError: URI malformed.

Caractères réservés et non réservés

La RFC 3986 classe les caractères en deux groupes :

  • Non réservés (section 2.3) : A-Z a-z 0-9 - . _ ~. Ils n'ont jamais besoin d'être encodés, et la norme demande de ne pas les encoder : %41 et A désignent la même chose.
  • Réservés (section 2.2) : les délimiteurs généraux : / ? # [ ] @ et les sous-délimiteurs ! $ & ' ( ) * + , ; =. Ils structurent l'URL.

La règle tient en une phrase : un caractère réservé s'écrit tel quel quand il joue son rôle de séparateur, et encodé quand il appartient à une donnée. Comparez :

https://exemple.fr/recherche?q=pain&lait      -> q = « pain », plus un paramètre « lait » vide
https://exemple.fr/recherche?q=pain%26lait    -> q = « pain&lait »

Tous les autres caractères, espaces, guillemets, % lui-même, accents, emojis, doivent être encodés partout. Le signe % s'écrit donc %25 : « 100 % » devient 100%20%25.

L'espace : %20 ou + ?

Selon la RFC 3986, une espace s'encode %20. Mais les formulaires HTML utilisent un autre format, application/x-www-form-urlencoded, dans lequel l'espace devient + et un vrai signe plus doit s'écrire %2B. Les navigateurs l'emploient pour les formulaires envoyés en GET, et la plupart des serveurs décodent les query strings de cette manière.

D'où une confusion fréquente. Dans un chemin, + est un simple signe plus. Dans une query string, il est en général lu comme une espace : une recherche sur « C++ » envoyée sans encodage, ?q=C++, arrive côté serveur sous la forme « C » suivi de deux espaces. Encodée correctement, elle s'écrit ?q=C%2B%2B.

LangageEspace en %20 (RFC 3986)Espace en + (formulaires)
JavaScriptencodeURIComponent()URLSearchParams
PHPrawurlencode()urlencode(), http_build_query()
Pythonurllib.parse.quote()quote_plus(), urlencode()

Les deux formes sont correctes dans une query string, à condition que le côté qui décode applique la même convention que celui qui encode.

encodeURI ou encodeURIComponent

JavaScript propose deux fonctions qui diffèrent par les caractères qu'elles laissent intacts. encodeURIComponent encode tout sauf A-Z a-z 0-9 - _ . ! ~ * ' ( ) : c'est la fonction à utiliser pour une valeur, qu'il s'agisse d'un paramètre ou d'un segment de chemin. encodeURI laisse en plus passer # $ & + , / : ; = ? @, car elle suppose qu'on lui donne une URL complète dont la structure est déjà correcte. Appliquée à une adresse construite à la main, elle laisse donc passer les séparateurs contenus dans les données :

encodeURI('https://exemple.fr/recherche?q=café & thé');
// 'https://exemple.fr/recherche?q=caf%C3%A9%20&%20th%C3%A9'
// le & n'est pas encodé : la valeur est coupée en deux

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

Le plus sûr reste de laisser l'API URL encoder chaque partie à sa place :

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'

Exemples en JavaScript, PHP et Python

Voici la même valeur encodée et décodée dans les trois langages, avec les deux conventions pour l'espace :

// 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 (fichier enregistré en 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é'

Deux détails à connaître. En Python, quote() laisse la barre oblique intacte par défaut : quote('a/b') renvoie a/b. Pour encoder une valeur isolée, passez safe='', qui donne a%2Fb. En PHP, urlencode() encode aussi le tilde en %7E, alors que rawurlencode() le laisse tel quel, comme le veut la RFC 3986.

Le double encodage

Encoder une chaîne déjà encodée transforme chaque % en %25. %20 devient %2520, et caf%C3%A9 devient caf%25C3%25A9. Avec la convention des formulaires, c'est le + qui trahit le problème : en PHP, urlencode(urlencode('a b')) renvoie a%2Bb, que le serveur décode en « a+b » au lieu de « a b ».

La cause est presque toujours la même : deux couches du code encodent la même valeur, par exemple une bibliothèque HTTP qui encode les paramètres qu'on lui passe déjà encodés. Le même piège existe au décodage : en PHP, $_GET contient des valeurs déjà décodées, et appeler urldecode() dessus décode une seconde fois, ce qui transforme notamment les + légitimes en espaces. La règle : encoder la valeur brute une seule fois, au moment où elle entre dans l'URL, et la décoder une seule fois à la lecture.

Un seul cas justifie une forme qui ressemble à du double encodage : une URL transmise comme paramètre d'une autre URL, par exemple dans redirect=. Si l'URL interne contient déjà %20, sa forme encodée contient %2520, et c'est correct : le destinataire décode une fois et retrouve l'URL d'origine.

Encoder et décoder en ligne

Pour vérifier une valeur sans écrire de code, l'encodeur d'URL propose les deux modes, encodeURIComponent et encodeURI, pour comparer leur résultat sur votre texte. Le décodeur d'URL décode au fil de la frappe et lit + comme une espace, à la manière des formulaires : un vrai signe plus doit donc y figurer sous la forme %2B. Tout se passe dans votre navigateur.

Questions Fréquentes

Pourquoi une espace devient-elle %20 dans une URL ?

Le code de l'espace est 32 en décimal, soit 20 en hexadécimal. L'encodage URL écrit un octet sous la forme % suivie de ses deux chiffres hexadécimaux, d'où %20.

Faut-il encoder les lettres accentuées dans une URL ?

Oui. Une lettre comme é est convertie en octets UTF-8, puis chaque octet est encodé : é devient %C3%A9. Le navigateur affiche souvent la forme décodée dans la barre d'adresse, mais la requête contient la forme encodée.

Quelle est la différence entre %20 et + ?

%20 est l'encodage standard de l'espace selon la RFC 3986. Le + ne signifie espace que dans les données de formulaire, c'est-à-dire en pratique dans les query strings ; dans un chemin, c'est un signe plus.

Quels caractères n'ont jamais besoin d'être encodés ?

Les caractères non réservés de la RFC 3986 : les lettres ASCII sans accent, les chiffres, le tiret, le point, le tiret bas et le tilde. Tous les autres doivent être encodés lorsqu'ils font partie d'une donnée.