Vous faites un POST d’une query string vers votre serveur, et dans le log de debug du serveur vous voyez q=hello%20world%26foo%3Dbar. Votre première réaction est probablement «est-ce que le middleware du serveur a été mal configuré ? y a-t-il un problème avec le parser ?». La réponse est, embarrassant, plus simple que ça : c’est l’encodage d’URL de la RFC 3986 (aussi appelé encodage par pourcentage) qui remplace automatiquement les caractères avant que la requête quitte le navigateur, et le serveur n’y est pour rien.

L’encodage par pourcentage est, au fond, ceci : prendre tout caractère qui est dangereux ou ambigu dans une URL et le remplacer par la forme «pourcentage + deux octets hexadécimaux». Donc un espace devient %20, un & devient %26, un = devient %3D. De cette façon, les URLs qui voyagent entre systèmes, protocoles et jeux de caractères ne se cassent pas parce que des systèmes différents interprètent le même octet différemment.

Mais la RFC 3986 ne définit que ce que fait l’encodage. Elle ne définit PAS qui encode, quand, ni combien de fois. Ces trois décisions sont prises indépendamment par les navigateurs, les frameworks serveur et les bibliothèques clientes, et les 8 bugs courants naissent tous de ce vide.

Ce billet parcourt les 8 pièges (foot-guns) les plus courants de l’URL encoding, chacun dans une structure en trois temps «symptôme → pourquoi → fix», et se termine par un outil recommandé qui tourne en local dans le navigateur, Piick URL Encoder / Decoder, qui offre 3 modes (chacun renvoyant la sortie correcte pour son domaine), détecte automatiquement le double encodage et affiche des chips d’avertissement pour vous permettre de sanity-checker votre round-trip en moins d’une minute.

Résumé en 30 secondes

  • URL encoding (encodage par pourcentage) est le standard RFC 3986 pour remplacer les caractères dangereux ou ambigus dans une URL par la forme %XX (où XX est la valeur hex de l’octet)
  • La boîte à outils JavaScript a trois pièces : encodeURIComponent (niveau composant, le défaut sûr), encodeURI (URL entière mais préserve /?&=# caractères syntaxiques d’URL — dangereux), et URLSearchParams (uniquement query strings)
  • Les 8 pièges courants : double encodage, plus vs %20, caractères réservés non encodés, point-virgule dans les segments de path (légal mais ambigu), Unicode non pré-encodé, state OAuth qui fait un round-trip à travers plusieurs sauts, application/x-www-form-urlencoded mélangé avec Content-Type application/json, comportement par défaut de décodage du serveur variable selon le langage
  • La direction du fix est toujours la même : encodez exactement une fois avec encodeURIComponent(value), encodez exactement une fois sur toute la chaîne, et ne réencodez jamais quelque chose que le saut précédent a déjà encodé
  • Utilisez Piick URL Encoder / Decoder pour encoder ou décoder en ligne, obtenez la sortie correcte pour chacun des 3 modes et gardez vos données locales. Les URLs de callback sensibles et les tokens d’API ne quittent jamais le navigateur

Les 8 pièges courants

Piège 1 : Double encodage (le %2520 n’est pas un échec de décodage)

Symptôme : vous recevez une chaîne comme %2520, lancez decodeURIComponent une fois et obtenez %20, lancez une deuxième fois et obtenez le vrai (espace). Votre première pensée est «où ai-je décodé une fois de trop ?».

Pourquoi ça arrive : un espace s’encode en %20 (quatre caractères : pourcentage, deux, zéro). Ces quatre caractères sont ensuite traités comme quatre caractères ordinaires %, 2, 0 et «encodés une nouvelle fois» par un framework ou par vous, produisant %2520. Chaque saut qui encode ajoute une couche ; si au moins un saut encode de manière redondante une valeur déjà encodée, vous obtenez un double encodage.

Sources courantes : framework serveur qui traite l’URL entière comme une chaîne brute et encode à nouveau ; ou URLSearchParams côté frontend qui auto-encode par-dessus un encodeURIComponent écrit à la main ; ou des redirects OAuth où chaque redirect ré-encode. Les trois sont le même bug à différentes échelles.

Fix : encodez exactement une fois sur toute la chaîne. Règle pratique : le client (navigateur) fait l’encodage, le serveur fait le décodage — et jamais l’inverse. Collez votre URL dans le mode Full URL de Piick et il détecte automatiquement le double encodage et affiche un chip d’avertissement, sans avoir à compter les %25 à l’œil.

Scénario réel : une callback OAuth2 arrive avec state=abc%2525xyz, le serveur décode une fois et obtient abc%25xyz, la couche métier compare avec l’original et les valeurs ne correspondent pas, le flux se termine avec state mismatch.

Piège 2 : Plus vs %20 (la bifurcation historique du form encoding)

Symptôme : le body d’un POST de formulaire contient q=hello+world et le serveur dit «je lis q comme hello world, pourquoi y a-t-il un + ?». Ou l’inverse : le path d’une URL est path=/hello world et le serveur lit path comme /hello world (espace non encodé).

Pourquoi ça arrive : quand on envoie un formulaire HTML, si la méthode du formulaire est GET ou si l’enctype est application/x-www-form-urlencoded, le navigateur encode les espaces dans les champs du formulaire comme + (PAS %20). C’est une convention héritée de HTML 4 qui s’applique uniquement aux bodies de formulaires et aux query strings des soumissions GET de formulaires. Dans les paths d’URL et les composants d’URL, les espaces s’encodent toujours comme %20. URLSearchParams est une troisième sémantique (elle traite + comme un caractère littéral, pas comme un espace). JS a trois règles, et les mélanger est la façon la plus simple d’expédier un bug.

Fix :

  • Les paths d’URL et les valeurs de query parameter utilisent le mode Component de Piick — toujours %20
  • Les bodies de formulaires et les soumissions GET de formulaires utilisent le mode Query de Piick+%20 automatique
  • Ne mélangez pas les trois ensembles de règles, même au sein d’une même app

Scénario réel : vous écrivez fetch('/api?q=' + userInput) où userInput vaut hello world. L’URL finale est /api?q=hello world (espace non encodé). Le router côté serveur tombe sur un espace, lance URIError: URI malformed, toute la requête meurt.

Piège 3 : Caractères réservés non encodés, le serveur coupe les champs

Symptôme : vous construisez ?q=foo&bar=baz en pensant obtenir un paramètre q=foo&bar=baz. Le serveur reçoit en réalité deux paramètres, q=foo et bar=baz, et le deuxième écrase le premier.

Pourquoi ça arrive : le parser d’URL voit & comme délimiteur entre paramètres quoi qu’il arrive. Donc q=foo&bar=baz sont deux paramètres indépendants. Pour traiter & comme caractère littéral dans la valeur, il faut l’encoder avec encodeURIComponent, qui vous donne %26.

Fix : traitez toujours les valeurs avec encodeURIComponent(value). Ne faites pas de concaténation brute de chaînes, n’utilisez pas encodeURI (qui préserve &), ne sautez pas l’encodage.

Scénario réel : un client GraphQL concatène une query dans l’URL — la query contient une requête GraphQL avec des accolades et des paramètres. Sans encodage, l’URL est tronquée à abc, le parser GraphQL du backend lance une erreur de syntaxe. 99 % de ces bugs sont celui-là.

Piège 4 : Point-virgule et slashs dans les segments de path, ambiguïté entre langages

Symptôme : un design de path d’URL RESTful /users/john;doe. Le backend Spring de Java traite ;doe comme paramètre matrix. Express de Node.js traite l’ensemble comme un segment ordinaire. Python Flask fait autre chose. Même URL, trois langages, trois résultats.

Pourquoi ça arrive : la RFC 3986 liste ; sous sub-delims, légal dans les paths, mais la sémantique est ambiguë — la RFC la définit comme sémantique OPTIONNELLE de paramètre matrix. / est le délimiteur de segments, mais une fois encodé (devenant %2F) différents frameworks suivent différentes conventions. Plusieurs comportements, pas de consensus.

Fix : utilisez encodeURIComponent sur chaque valeur à l’intérieur de chaque segment de path (n’encodez pas le segment entier, seulement les valeurs). Si vous devez utiliser des paramètres matrix, fixez-vous sur un seul framework et documentez-le ; ne sautez pas entre langages.

Scénario réel : les premiers paths de l’API Twitter contenaient des points-virgules (/statuses/show/:id.json;count=10). Les clients Python et le SDK Java officiel parseaient ces paths différemment, et les ingénieurs cross-langage avaient une tâche de debug quotidienne.

Piège 5 : Unicode non pré-encodé, le UTF-8 / GBK du serveur se mélange

Symptôme : une URL contient du chinois comme «用户搜索». Le frontend envoie la requête directement. Le Nginx du serveur plus le backend renvoient «la query est illisible» ou URIError: URI malformed.

Pourquoi ça arrive : encodeURIComponent("用户") produit %E7%94%A8%E6%88%B7 (séquence d’octets UTF-8). Concaténer directement des caractères non ASCII dans une URL est une violation de RFC — la RFC définit un sous-ensemble ASCII plus une extension d’échappement par pourcentage mais ne spécifie pas comment les caractères non ASCII sont transmis. Même quand Nginx est configuré avec charset utf-8, il gère les URLs au niveau de l’octet et ne devinera pas le jeu de caractères pour vous.

Fix :

  • Frontend : toujours encodeURIComponent(value) avant la concaténation. Ça produit naturellement des séquences d’octets UTF-8.
  • Côté serveur : n’essayez PAS de «détecter automatiquement le jeu de caractères». Établissez que les clients doivent encoder d’abord.
  • Pendant les tests : ouvrez DevTools, regardez l’onglet Network. Si la ligne de requête est déjà %E7%94..., vous êtes bon.

Scénario réel : un utilisateur à l’étranger cherche «手机» sur un site e-commerce chinois. Le frontend n’encode pas, le serveur tente un décodage GBK et obtient du charabia, les résultats de recherche ne correspondent pas à la recherche, le taux de conversion chute de moitié.

Piège 6 : state OAuth qui fait un round-trip à travers plusieurs sauts, défense CSRF cassée

Symptôme : la callback OAuth 2.0 arrive avec state. Localement vous encodez et passez au serveur. Le serveur encode à nouveau ou encode implicitement. Quand le redirect atteint l’endpoint final, state ne correspond pas à la valeur d’origine et le flux se termine.

Pourquoi ça arrive : le paramètre state d’OAuth est conçu pour être l’identité round-trip de la requête originelle (défense CSRF). Il doit voyager génération locale → encodage → URL → réseau → serveur → décodage → comparaison. Différentes bibliothèques OAuth gèrent les défauts d’encode/decode différemment (Auth0 encode par défaut, NextAuth non, Spring Security fait autre chose), et les collaborations cross-langage cross-bibliothèque se cassent.

Fix :

  • Une fois engagé sur une bibliothèque OAuth, utilisez la convention d’encodage qu’elle recommande. N’empilez PAS un encodeURIComponent manuel par-dessus.
  • Auto-test : encodage local → construction URL → décodage serveur → comparaison → doit correspondre
  • Utilisez le mode Query de Piick URL Encoder / Decoder pour comparer raw et décodé aux deux extrémités

Scénario réel : lors de l’intégration Notion ou Google OAuth, les bugs de mismatch du state par défaut surgissent 3-5 fois en une semaine. La cause habituelle est que le développeur ajoute son propre encodeURIComponent par-dessus ce que la bibliothèque fait déjà.

Piège 7 : application/x-www-form-urlencoded mélangé avec Content-Type JSON

Symptôme : vous dites «mon body de POST est du JSON» mais fetch ajoute Content-Type: application/x-www-form-urlencoded (ou l’inverse, le body est en format urlencoded mais le Content-Type dit JSON), et le serveur rejette ou parse mal.

Pourquoi ça arrive : le Content-Type application/x-www-form-urlencoded force le body à passer par le parser urlencoded, qui traite chaque accolade ouvrante, accolade fermante, deux-points et guillemet du body comme caractère illégal ou caractère littéral. À l’inverse, le parser application/json attend que le body soit du JSON valide, et voir du format urlencoded déclenche SyntaxError.

Fix : le Content-Type doit correspondre au format réel du body. JSON utilise application/json et le body est du vrai JSON. Les formulaires utilisent application/x-www-form-urlencoded et le body est key=value&key2=value2.

Scénario réel : piège classique de debug de webhook — Stripe envoie un body JSON de webhook, vous copiez la commande curl et changez le Content-Type en urlencoded, le serveur parse la chaîne JSON comme paramètres littéraux, chaque champ revient null.

Piège 8 : Le comportement de décodage par défaut côté serveur est incohérent entre langages

Symptôme : votre code decodeURIComponent(req.url) explose sur Node.js Express avec URIError: URI malformed. Vous pensez que le serveur a un bug, mais le serveur a déjà décodé avant que vous n’arriviez.

Pourquoi ça arrive :

  • Node.js / Nginx / Apache / Go net/http / Spring / divers frameworks serveur prennent des décisions différentes sur la pré-décodage des URLs
  • La convention habituelle : le path a été décodé une fois, le query a été décodé une fois (implicite)
  • Express ne décode PAS par défaut. Donc quand vous écrivez decodeURIComponent, req.url a déjà été décodé une fois et vous obtenez URIError.
  • Mais req.originalUrl montre le raw, vous ne pouvez pas le comparer directement.

Fix :

  • Lisez la documentation officielle, découvrez le comportement de décodage par défaut du framework
  • Utilisez le mode Component de Piick : un encodage, un décodage, pas de double entre les deux
  • Quand le stack est en couches (Express + Nginx + URL rewriter), regardez l’access.log et le debug.log pour repérer la position du %20 — cela indique quelle couche a introduit un encodage supplémentaire

Scénario réel : une app e-commerce à trois couches (Nginx + Express + ORM) reçoit des rapports de bug «les paramètres de requête sont parfois mal parsés». La cause racine est que le framework ORM décode une fois en interne, Nginx décode une fois, Express décode à nouveau — trois décodages au total, les données sont emmêlées.

Décisions de choix d’outil

Trois voies principales pour l’encodage/décodage d’URL, chacune adaptée à un scénario différent :

Outil en ligne côté navigateur (ce billet / cet outil)

Points forts : zéro installation, zéro upload, convivial pour les URLs de callback sensibles et les tokens d’API. Trois modes (Component / Query / Full URL) auto-déterminent les règles d’encodage correctes, l’auto-détection de double encodage affiche des chips d’avertissement. Convient au debug ponctuel, au triage d’incidents en production, au dépannage du state OAuth. Piick URL Encoder / Decoder est cette voie — essayez-le.

Fonctions natives Node.js / navigateur (dans votre code)

  • encodeURIComponent(str) + decodeURIComponent(str) — niveau composant, le défaut quotidien
  • encodeURI(str) + decodeURI(str) — URL entière, préserve /?&=# caractères syntaxiques d’URL. À moins de savoir exactement ce que vous faites, n’utilisez pas ça.
  • new URL(str) — parse une URL entière, mutez les parties, puis .toString()
  • URLSearchParams — query strings uniquement. Ne traite PAS le + brut comme un espace (l’inverse du form encoding)

Convient aux pipelines de build, aux tests unitaires, à l’automatisation. Le footer de la page de l’outil Piick contient la table complète des règles de la RFC 3986 pour cross-référencement.

Middleware du framework serveur (Express / Spring / Rails intégré)

Le framework gère le round-trip de routine pour que vous n’ayez pas à vous en soucier. Inconvénient : les cas limites (query contenant ;, espaces, Unicode) le framework ne peut pas aider ; vous avez toujours besoin d’un decodeURIComponent manuel en fallback. Convient aux apps web CRUD, déconseillé dans les scénarios de haute sécurité ou haute complexité.

Recommandation de workflow cross-outil : debug avec un outil navigateur → écrivez le code avec encodeURIComponent → lancez des tests unitaires de round-trip en CI → avant la signature du webhook, passez l’URL canonique par le mode Full URL de Piick, puis enchaînez sur le validateur de signature webhook pour vérifier que les signatures correspondent.

5 scénarios réels

Scénario 1 : Mismatch de state dans une callback OAuth2

Anti-pattern : encodage local, puis le serveur encode automatiquement à nouveau, le round-trip échoue. Fix : fixez-vous sur le comportement d’encodage de la bibliothèque OAuth, n’empilez pas d’encodage manuel par-dessus. Vérifiez le round-trip en comparant le résultat décodé de l’extrémité locale avec le résultat décodé du serveur dans le mode Query de Piick.

Scénario 2 : URL de webhook avec query, normaliser avant de signer

Préparer une signature de webhook (Stripe / GitHub / Slack et autres) utilise typiquement une chaîne canonique : normalisez l’URL (retirez le host, triez les keys du query, URL encode, puis hachez le body, enfin HMAC avec le secret). Dans cette chaîne, l’encodage d’URL se fait exactement une fois, à l’étape de chaîne canonique. Le mode Full URL de Piick vous aide à voir exactement à quoi ressemble la chaîne encodée. Après la signature, vérifiez avec le validateur de signature de webhook pour confirmer que la signature correspond.

Scénario 3 : Vous voyez %20 dans les logs mais c’est un souci de configuration

Quand l’access.log de Nginx montre des chaînes doublement encodées comme %2520 et %2526, la cause est généralement un saut dans la chaîne de redirects qui encode de manière redondante. Localisez-le avec grep + décodage inverse :

Par exemple, faites un grep dans l’access.log sur toutes les entrées qui contiennent %25, puis faites un grep sur la chaîne de redirect de cette [URL] précise (les premières entrées) pour voir à quel saut %2520 apparaît en premier — c’est le saut qui a le bug.

À quel saut %2520 apparaît-il en premier ? C’est le saut qui a le bug.

Scénario 4 : Le frontend construit l’URL de recherche sans encoder, le serveur coupe les champs

Anti-pattern : fetch construit l’URL de recherche par concaténation de template de string où userInput vaut foo & bar. L’URL devient /api/search?q=foo & bar, l’espace n’est pas encodé, & est traité comme séparateur de paramètres. Fix : enveloppez avec encodeURIComponent(userInput). C’est la cause racine la plus fréquente des tickets «pourquoi mon paramètre de query est faux».

Scénario 5 : Le path d’une URL REST contient des caractères spéciaux

Anti-pattern : GET /api/users/john doe (avec espace). Le backend renvoie 404. Fix : encodeURIComponent('john doe') donne john%20doe, l’URL finale est /api/users/john%20doe, le backend reçoit «john doe».

Pratiques recommandées

  • Toujours encodeURIComponent(value) pour les valeurs. Jamais encodeURI, jamais sauter l’encodage.
  • Encodez exactement une fois sur toute la chaîne : le client (navigateur) fait l’encodage, le serveur fait le décodage, jamais l’inverse. Dans les scénarios multi-sauts comme OAuth ou Webhooks, si chaque saut réencode vous obtenez un double encodage. La règle est : encodez exactement une fois sur toute la chaîne.
  • Test de round-trip avant production : collez votre valeur d’origine dans Piick, regardez la sortie encodée, collez cette sortie dans l’environnement de test, décodez et vérifiez que vous obtenez la même valeur. Ce sanity check prend 5 secondes.
  • Ne mélangez pas les trois ensembles de règles : les composants d’URL utilisent le mode Component, les query strings utilisent le mode Query, les bodies de formulaires utilisent urlencoded. N’utilisez pas un ensemble côté JS et un autre côté Python.
  • Les URLs de callback sensibles et les tokens d’API ne quittent jamais votre navigateur : utilisez Piick URL Encoder / Decoder en ligne, 100 % données locales, rien n’est uploadé.

URL encoding est une infrastructure inévitable enfouie dans le protocole HTTP, mais la RFC 3986 vous donne le «quoi» — le «comment» est décidé par votre stack. Après suffisamment de debug, vous connaîtrez les pièges par cœur, mais en attendant, mettez Piick URL Encoder / Decoder en favori, ouvrez-le pour un sanity check d’une minute quand quelque chose cloche, ne perdez pas de temps.