Aller au contenu
OneKitly

L'encodage d'URL expliqué : le pourcentage et là où ça coince

Publié le 07/07/2026 · 17 min de lecture · Outils pour développeurs

Daniel Okonkwo

Daniel OkonkwoDéveloppeur front-end et rédacteur Tech chez OneKitly

Performance web · Formats de fichiers

Vérifié à partir de 6 sources

Voir le profil
En bref

L'encodage par pourcentage remplace un octet par un signe pourcent et deux chiffres hexadécimaux. Quels octets doivent l'être dépend de la partie d'URL où tu es, et c'est pourquoi le sujet paraît incohérent. La RFC 3986 définit des caractères non réservés qui n'ont jamais besoin d'encodage — A-Z, a-z, 0-9, tiret, point, tiret bas et tilde — et des caractères réservés porteurs de sens structurel : les gen-delims : / ? # [ ] @ et les sub-delims ! $ & ' ( ) * + , ; = . Un caractère réservé doit être encodé lorsqu'il apparaît comme donnée et non comme structure. Ainsi / est parfaitement légal dans un chemin et doit devenir %2F dans une valeur de requête, faute de quoi il serait lu comme un morceau de chemin. JavaScript te donne trois fonctions qui divergent là-dessus. Exécute-les sur a b/c?d=café+e&f#g~h*i(j) : encodeURI rend a%20b/c?d=caf%C3%A9+e&f#g~h*i(j), encodeURIComponent rend a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j), et l'obsolète escape rend a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29. Utilise encodeURIComponent pour chaque valeur individuelle, encodeURI seulement pour une URL entière déjà fiable, et escape jamais — il produit du Latin-1, donc é devient %E9 au lieu du correct UTF-8 %C3%A9.

L'encodage par pourcentage se décide composant par composant, et c'est de là que vient toute la confusion. Une barre oblique est légale dans un chemin et doit être échappée dans une valeur de requête ; une espace est %20 dans un chemin et peut être + dans un corps de formulaire. Voici les ensembles exacts de la RFC 3986, les trois fonctions JavaScript qui divergent, et les pièges.

La règle est par composant, pas par URL

Une URL n'est pas une chaîne unique, c'est une suite de parties étiquetées : schéma, hôte, chemin, requête, fragment. Chaque partie a sa propre idée de ce qui est structure et de ce qui est donnée, et l'encodage par pourcentage existe pour les distinguer. Une barre oblique dans un chemin est de la structure — elle sépare les segments — elle reste donc telle quelle. La même barre dans une valeur de requête est une donnée, et doit s'écrire %2F, sinon l'analyseur de la requête n'a aucun moyen de savoir que tu voulais un caractère littéral et non un morceau de chemin égaré.

La conséquence apparaît dès que tu construis une URL par concaténation. Supposons qu'un nom de fichier soit 2026/08 report.pdf et doive tenir dans un segment de chemin. Encode la valeur et tu obtiens /files/2026%2F08%20report.pdf, un seul segment comme prévu. Saute l'encodage et tu obtiens /files/2026/08 report.pdf, trois segments et une espace, pointant vers quelque chose qui n'existe pas. La même asymétrie frappe les valeurs de requête : ?note=rock&roll s'analyse en deux paramètres, note valant rock et roll vide, tandis que ?note=rock%26roll s'analyse comme la valeur unique que tu voulais.

Réservés et non réservés, exactement comme les définit la RFC 3986

L'ensemble non réservé est petit et mérite d'être appris par cœur : les lettres A à Z dans les deux casses, les chiffres 0 à 9, et exactement quatre signes de ponctuation — tiret, point, tiret bas et tilde. Ceux-là n'ont jamais besoin d'encodage nulle part dans une URL, et les encoder quand même est légal mais inutile, puisque %41 et A désignent le même caractère et qu'un analyseur conforme les traite à l'identique.

L'ensemble réservé se divise en deux. Les gen-delims sont les caractères qui séparent les composants majeurs : deux-points, barre oblique, point d'interrogation, dièse, crochets ouvrant et fermant, et arobase. Les sub-delims structurent l'intérieur d'un composant : point d'exclamation, dollar, esperluette, apostrophe, parenthèses ouvrante et fermante, astérisque, plus, virgule, point-virgule et signe égal. Tout ce qui n'est ni non réservé ni réservé — caractères de contrôle, espace, guillemet droit, chevrons, barre oblique inverse, accent circonflexe, accent grave, accolades, barre verticale, et tout octet au-dessus de 127 — doit toujours être encodé.

Un détail piège les gens. encodeURIComponent laisse intacts le point d'exclamation, l'apostrophe, les parenthèses et l'astérisque, et ces quatre-là sont des sub-delims selon la RFC 3986. Ces caractères sont légaux là où la fonction s'emploie normalement, donc c'est sans danger dans les cas ordinaires ; mais si tu produises une valeur pour un système qui suit strictement la RFC 3986 — certains schémas de signature et canonicalisations de type OAuth le font — il faut les échapper toi-même ensuite. Note aussi que les crochets sont toujours échappés par les deux encodeurs JavaScript, donnant %5B et %5D, car ils ont été ajoutés à l'ensemble réservé pour les littéraux IPv6 après la spécification de ces fonctions.

Trois fonctions JavaScript sur une même chaîne, et l'exception du signe plus

Prends a b/c?d=café+e&f#g~h*i(j) et exécute les trois. encodeURI produit a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) : l'espace et la lettre accentuée sont encodés, tout ce qui est structurel reste intact. encodeURIComponent produit a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j) : barre oblique, point d'interrogation, égal, plus, esperluette et dièse sont tous échappés, car dans l'univers de cette fonction la chaîne entière est une seule valeur. escape produit a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29, différent des deux autres.

Énumère la plage ASCII et la différence devient précise. Les deux fonctions modernes divergent sur exactement onze caractères : # $ & + , / : ; = ? @ sont laissés intacts par encodeURI et échappés par encodeURIComponent. Cette liste est l'ensemble réservé, ce qui te dit à quoi servent les deux fonctions. encodeURI suppose que la chaîne est déjà une URL complète dont les délimiteurs doivent survivre ; encodeURIComponent suppose que la chaîne est une valeur unique à qui il ne faut permettre d'introduire aucun délimiteur.

escape est d'une autre espèce et ne devrait jamais servir. Elle précède les spécifications modernes et encode en Latin-1 plutôt qu'en UTF-8 : é devient %E9 au lieu du correct %C3%A9, et tout ce qui dépasse U+00FF devient une séquence non standard %uXXXX, si bien que le signe euro sort en %u20AC. Elle laisse aussi intacts le plus, l'arobase et la barre oblique, tous dangereux dans une valeur de requête, tout en échappant inutilement le tilde et les parenthèses. Elle ne survit dans le langage que pour la rétrocompatibilité, dans l'annexe réservée aux fonctionnalités qui existent mais sur lesquelles il ne faut pas compter.

L'encodage par pourcentage tel que défini par la RFC 3986 n'a qu'une seule représentation de l'espace : %20. Elle fonctionne partout — chemin, requête, fragment. Le signe plus valant espace appartient à un mécanisme différent et plus ancien : la sérialisation application/x-www-form-urlencoded des formulaires HTML, où les espaces deviennent des plus et où un plus littéral doit devenir %2B. Les navigateurs utilisent cette forme pour la chaîne de requête d'une soumission de formulaire en GET, d'où la cohabitation des deux conventions dans les chaînes de requête rencontrées en pratique.

La conséquence est un bug de décodage facile à écrire et difficile à voir. decodeURIComponent("a+b") renvoie a+b, le plus intact, car decodeURIComponent implémente la RFC 3986 et ignore tout de l'encodage de formulaire. Donne la même chaîne à un analyseur conscient des formulaires et tu obtiens a b. Le bon décodeur dépend donc de la façon dont la chaîne a été produite. En pratique, le geste fiable est de cesser de bricoler les deux côtés : construis les chaînes de requête avec URLSearchParams, qui sérialise une espace en + et échappe un plus littéral en %2B, et relis-les avec URLSearchParams, qui inverse exactement les mêmes règles.

Le non-ASCII passe d'abord par UTF-8

L'encodage par pourcentage travaille sur des octets, pas sur des caractères : un caractère non-ASCII doit donc être converti en octets avant d'être échappé. La règle moderne est UTF-8, puis un échappement par octet. é est un caractère unique encodé en deux octets c3 a9, il devient donc %C3%A9. Le signe euro fait trois octets, e2 82 ac, il devient %E2%82%AC — neuf caractères pour un symbole. Un emoji tel que U+1F600 fait quatre octets et devient %F0%9F%98%80, douze caractères.

C'est là que escape te trahit, puisqu'il envoie ce même é sur l'octet unique %E9, son point de code Latin-1. Un serveur qui décode en UTF-8 voit une séquence d'octets invalide et lève une erreur ou produit un caractère de remplacement, et la panne n'apparaît que sur les lignes accentuées de tes données. La RFC 3986 n'impose pas elle-même d'encodage de caractères — elle précède l'adoption universelle d'UTF-8 et se contente de le recommander pour les nouveaux schémas — mais le standard URL du WHATWG, que les navigateurs implémentent réellement, spécifie UTF-8 partout. Considère UTF-8 comme la seule réponse correcte.

Le double encodage, et comment naît %2520

Le signe pourcent est lui-même un caractère réservé : encoder une chaîne déjà encodée échappe donc les échappements. Pars de a b. Encode une fois : a%20b. Encode ce résultat : a%2520b, car le pourcent est devenu %25. Encode encore : a%252520b. Chaque tour ajoute trois caractères et une passe de décodage supplémentaire obligatoire, et la chaîne grossit sans jamais lever d'erreur.

Dans les systèmes réels, cela se produit quand une valeur traverse plusieurs couches qui, serviables, encodent chacune ce qu'on leur a remis : un client encode, une passerelle réencode, un framework encode à l'entrée d'un gabarit. Le symptôme est un %20 littéral apparaissant dans une page ou un nom de fichier là où devrait être une espace, ou un 404 sur un chemin qui semble correct. Le remède est une discipline, pas une astuce : désigne exactement un endroit du pipeline propriétaire de l'encodage, encode là, et transporte des valeurs brutes partout ailleurs. Si tu dois décoder défensivement, décode une fois et vérifie si le résultat contient encore un pourcent suivi de deux chiffres hexadécimaux avant de décider de recommencer — et sache que le décodage répété à l'aveugle est lui-même un problème de sécurité, car il peut transformer %252e%252e en .. et rouvrir une traversée de chemin qu'un seul décodage avait fermée.

L'hôte relève d'un autre mécanisme : IDN et punycode

L'encodage par pourcentage ne s'applique pas au nom de domaine. Les étiquettes DNS se limitent aux lettres, chiffres et traits d'union : les domaines internationalisés utilisent donc une transformation entièrement distincte, le Punycode, défini par la RFC 3492 et encadré par les spécifications IDNA. münchen.de devient xn--mnchen-3ya.de, bücher.example devient xn--bcher-kva.example, et un domaine japonais tel que les deux caractères désignant le Japon suivis de .jp devient xn--wgv71a.jp. Le préfixe xn-- signale une étiquette encodée ; suivent les caractères ASCII dans l'ordre, un séparateur, puis les instructions de réinsertion des caractères non-ASCII.

On peut voir les deux mécanismes fonctionner côte à côte. Analyse https://münchen.de/straße?q=über alles avec un analyseur d'URL standard et le résultat est https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles : l'hôte est passé par Punycode, le chemin et la requête par l'encodage UTF-8 en pourcentage, et aucun n'a empiété sur le territoire de l'autre. Cette séparation n'est pas cosmétique. C'est pourquoi une séquence en pourcentage dans un nom d'hôte n'est pas décodée comme on pourrait le croire, et pourquoi les attaques par homographe — enregistrer un domaine dont les caractères Unicode ressemblent à ceux d'un autre — sont un problème de la couche Punycode, que les navigateurs traitent par des règles d'affichage et non par l'encodage.

Quatre règles qui évitent l'essentiel des ennuis

Primo, n'assemble jamais une URL par concaténation de chaînes quand un constructeur d'URL est disponible. new URL() et URLSearchParams savent dans quel composant ils se trouvent et encodent en conséquence, savoir qui manque précisément à un gabarit de chaîne. Deuxio, encode des valeurs, pas des URL : applique encodeURIComponent à chaque segment de chemin et à chaque valeur de requête, et réserve encodeURI à une URL achevée que tu as construite. Tertio, encode une seule fois, à une seule couche propriétaire, et transporte des valeurs brutes partout ailleurs — cela seul supprime toute la classe des bugs en %2520. Quarto, supprime escape de ta base de code ; il encode en Latin-1 et laisse passer des caractères dangereux, et il n'existe aucune situation où il soit la bonne réponse.

La même chaîne dans quatre encodeurs, avec les caractères ASCII que chacun laisse intacts
EncodeurSortie sur a b/c?d=café+e&f#g~h*i(j)Laisse intacts (au-delà des lettres et chiffres)Non-ASCIIÀ utiliser pour
encodeURIComponenta%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j)- . _ ~ ! ' ( ) *UTF-8 puis pourcentageChaque valeur individuelle : un segment de chemin, une valeur de requête, un fragment
encodeURIa%20b/c?d=caf%C3%A9+e&f#g~h*i(j)Tout ce que garde l'encodeur de composant, plus # $ & + , / : ; = ? @UTF-8 puis pourcentageUne URL entière que tu as assemblée et à laquelle tu te fies déjà
escape (obsolète)a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29* + - . / @ _ — et il échappe ~ ( ) que les autres gardentLatin-1 jusqu'à U+00FF, puis le non standard %uXXXXRien. Il n'est dans la norme que pour la rétrocompatibilité
URLSearchParams (encodage de formulaire)q=a+b pour la valeur a b ; a/b+c devient a%2Fb%2BcMême ensemble non réservé, mais une espace devient + et non %20UTF-8 puis pourcentageConstruire une chaîne de requête ou un corps x-www-form-urlencoded
Encodeur / décodeur d'URLEncode et décode des URL et paramètres de requête.Essayer l'outil

Questions fréquentes

Quelle est la différence entre encodeURI et encodeURIComponent ?
Exactement onze caractères. encodeURI laisse intacts # $ & + , / : ; = ? @ ; encodeURIComponent les échappe tous. Cette liste est l'ensemble réservé de la RFC 3986, et elle te dit ce que chaque fonction suppose. encodeURI croit qu'on lui a remis une URL complète dont les délimiteurs doivent continuer de fonctionner : il n'échappe donc que ce qui ne pourrait jamais être structurel — espaces, non-ASCII, caractères de contrôle. encodeURIComponent croit qu'on lui a remis une valeur unique qui ne doit pouvoir introduire aucun délimiteur : il échappe donc tout le réservé. Sur la chaîne a b/c?d=café+e&f#g~h*i(j), le premier rend a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) et le second rend a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j). Utilise encodeURIComponent pour chaque segment de chemin et chaque valeur de requête, ce qui est presque toujours ce que tu veux. N'utilise encodeURI que sur une URL entière que tu as assemblée et à laquelle tu te fies déjà — l'appliquer à une saisie utilisateur ne rend pas cette saisie sûre, puisqu'il préserve délibérément les caractères dont une valeur injectée aurait besoin.
Une espace doit-elle être %20 ou un signe plus ?
%20 est toujours correct ; le signe plus n'est correct que dans un contexte précis. L'encodage par pourcentage de la RFC 3986 n'a qu'une représentation de l'espace, %20, valide dans le chemin, la requête et le fragment. Le plus valant espace vient d'application/x-www-form-urlencoded, la sérialisation plus ancienne des formulaires HTML, où les espaces deviennent des plus et où un plus littéral doit s'écrire %2B. Les navigateurs l'appliquent lors de la soumission d'un formulaire en GET, d'où la présence des deux conventions dans les vraies chaînes de requête. Le danger pratique est du côté décodage : decodeURIComponent("a+b") renvoie a+b, plus intact, car il implémente la RFC 3986 et ignore les formulaires, alors qu'un analyseur conscient des formulaires renvoie a b. Ne décide pas au cas par cas. Construis les chaînes de requête avec URLSearchParams et relis-les avec URLSearchParams, pour que les mêmes règles s'appliquent dans les deux sens. Dans un segment de chemin, utilise toujours %20 — un plus y est un caractère plus littéral, rien d'autre.
Pourquoi vois-je %2520 dans mes URL ?
Parce que quelque chose a encodé une chaîne déjà encodée. Le signe pourcent est lui-même réservé : il devient %25 une fois échappé. Prends a b, encode en a%20b, puis encode ce résultat : le pourcent devient %25 et tu obtiens a%2520b. Recommence et tu obtiens a%252520b. Rien ne plante, la chaîne grossit simplement de trois caractères par tour et exige une passe de décodage supplémentaire. Dans les systèmes réels, cela arrive quand plusieurs couches encodent poliment chacune ce qu'on leur a remis — un client, puis une passerelle, puis un framework qui rend dans un gabarit. Le symptôme est un %20 littéral là où devrait être une espace, ou un 404 sur un chemin qui semble juste. Le correctif est architectural : désigne exactement une couche propriétaire de l'encodage, encode là, et fais circuler des valeurs brutes partout ailleurs. Évite de décoder en boucle jusqu'à disparition des pourcents : c'est une vulnérabilité en soi — un décodage répété peut transformer %252e%252e en .. et rouvrir une traversée de chemin qu'un seul décodage avait contenue.
Faut-il encoder les caractères non anglais dans une URL ?
Oui, et l'encodage passe d'abord par UTF-8. L'encodage par pourcentage travaille sur des octets : un caractère doit donc devenir des octets avant d'être échappé, et la règle moderne est d'encoder en UTF-8 puis d'écrire un échappement par octet. é fait deux octets, c3 a9, et devient %C3%A9. Le signe euro fait trois octets et devient %E2%82%AC, neuf caractères pour un symbole. Un emoji en U+1F600 fait quatre octets et devient %F0%9F%98%80. Cela compte pour les tailles de stockage et pour toute limite de longueur que tu imposes, un seul caractère pouvant en coûter douze. Cela explique aussi pourquoi la fonction obsolète escape corrompt les données : elle produit du Latin-1, transformant é en l'octet unique %E9, qu'un décodeur UTF-8 rejette comme invalide — et la casse n'apparaît que sur tes enregistrements accentués. L'hôte fait exception : les noms de domaine ne sont pas encodés en pourcentage mais convertis en Punycode, si bien que münchen.de devient xn--mnchen-3ya.de tandis que le chemin et la requête à côté utilisent l'encodage UTF-8 ordinaire.
L'encodage par pourcentage suffit-il à rendre une saisie utilisateur sûre ?
Non, car l'encodage est contextuel et une URL n'est qu'un des contextes traversés par une valeur. encodeURIComponent empêche une valeur de s'échapper de son composant d'URL : une barre devient %2F et ne peut plus ouvrir un segment de chemin, une esperluette devient %26 et ne peut plus ouvrir un paramètre. C'est réel et important. Cela ne fait rien pour l'étape suivante. La même valeur placée dans du HTML exige un échappement HTML, placée dans une requête SQL exige une requête paramétrée, placée dans une commande shell exige une mise entre guillemets au niveau des arguments, placée dans un littéral de chaîne JavaScript exige son propre échappement. Encoder pour le mauvais contexte n'est pas une protection partielle : c'est une absence de protection. Deux mises en garde de plus : encodeURI n'est pas un assainisseur d'entrée, puisqu'il préserve délibérément les caractères réservés dont se servirait un attaquant — ne l'applique jamais à des données non fiables comme mesure de sécurité ; et décoder de façon répétée jusqu'à disparition des pourcents peut reconstruire des séquences qu'un seul décodage avait neutralisées, notamment retransformer %252e%252e en traversée de chemin.
Comment les noms de domaine internationalisés s'insèrent-ils là-dedans ?
Ils n'utilisent pas du tout l'encodage par pourcentage. Les étiquettes DNS se limitent aux lettres, chiffres et traits d'union : un domaine contenant autre chose est converti en Punycode, défini par la RFC 3492 et encadré par les spécifications IDNA. münchen.de devient xn--mnchen-3ya.de, bücher.example devient xn--bcher-kva.example, et un domaine japonais tel que les deux caractères désignant le Japon suivis de .jp devient xn--wgv71a.jp. Le préfixe xn-- signale l'étiquette comme encodée, et le reste contient les caractères ASCII suivis des instructions pour replacer les autres. On voit les deux systèmes d'un coup en analysant une URL comme https://münchen.de/straße?q=über alles : le résultat est https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles, Punycode sur l'hôte et encodage UTF-8 en pourcentage partout ensuite. Concrètement, n'encode jamais un nom d'hôte en pourcentage, et compare les noms d'hôte sous leur forme Punycode. C'est aussi pourquoi les attaques par homographe relèvent de la politique d'affichage des navigateurs et non de l'encodage — les deux noms sont réellement des étiquettes distinctes qui se ressemblent seulement.

Articles qui pourraient t'intéresser

Tous les guides
ExplicationQu'est-ce que l'encodage Base64 ?Le Base64 transforme des données binaires en texte sûr. Voici ce qu'il fait, pourquoi il existe, pourquoi ce n'est pas du chiffrement, et le surcoût de taille qu'il ajoute.ExplicationQu'est-ce qu'un JWT (JSON Web Token) ?Un JWT est un jeton compact et signé qui transporte l'identité entre services. Voici ses trois parties, son usage pour l'authentification et ses limites de sécurité.GuideConstruire une URL avec paramètres qui survit à un copier-collerTrois encodages, une différence visible : %20 ou +. Le mode formulaire du générateur reproduit URLSearchParams octet pour octet sur dix-sept valeurs — mais donne-lui une URL de base avec un fragment et tous les paramètres atterrissent dans le hash, invisibles du serveur.ExplicationCe qu'il y a dans un JWT — et ce qu'il ne protège pasUn JWT est signé, pas chiffré. Quiconque détient le jeton peut décoder la charge utile et lire chaque revendication. Voici un vrai jeton, décodé sans aucune clé, plus les trois attaques que la signature est censée arrêter et le seul problème qu'elle ne peut pas résoudre.ComparatifJSON vs XML : quelle différence ?JSON et XML stockent tous deux des données structurées en texte, mais avec des compromis différents. Voici à quoi ressemble chacun, où chacun l'emporte et comment choisir.ExplicationQu'est-ce qu'un QR code ?Un QR code est un code-barres 2D qu'une caméra lit pour ouvrir un lien ou du texte. Voici ce qu'il est, pourquoi il contient tant, son anatomie, et une note de sécurité.

Outils similaires

Sources

Tu as repéré une erreur dans cet article ?