Haces POST de una query string a tu servidor, y en el log de debug del servidor ves q=hello%20world%26foo%3Dbar. Tu primera reacción probablemente es: «¿se desconfiguró el middleware del servidor? ¿algo está mal en el parser?». La respuesta es, vergonzosamente, más simple que eso: esto es la codificación de URL de RFC 3986 (también llamada codificación por porcentaje) que reemplaza caracteres automáticamente antes de que la solicitud salga del navegador, y el servidor no tiene nada que ver con eso.

La codificación por porcentaje es, en el fondo, esto: tomar cualquier carácter que sea inseguro o ambiguo dentro de una URL y reemplazarlo con la forma «porcentaje + dos bytes hexadecimales». Entonces un espacio se convierte en %20, un & en %26, un = en %3D. De esta forma, las URLs que viajan entre sistemas, protocolos y conjuntos de caracteres no se rompen porque distintos sistemas interpretan el mismo byte de forma diferente.

Pero RFC 3986 solo define qué «hace» la codificación. NO define quién codifica, cuándo, ni cuántas veces. Esas tres decisiones las toman de forma independiente los navegadores, los frameworks de servidor y las librerías cliente, y los 8 bugs comunes nacen todos de ese hueco.

Este post recorre los 8 foot-guns más comunes de URL encoding, cada uno en una estructura de tres tiempos «síntoma → por qué → fix», y termina con una herramienta recomendada que corre local en el navegador, Piick URL Encoder / Decoder, que viene con 3 modos (cada uno devuelve la salida correcta para su dominio), detecta automáticamente la doble codificación y muestra chips de advertencia para que puedas sanity-checkear tu round-trip en menos de un minuto.

Resumen en 30 segundos

  • URL encoding (codificación por porcentaje) es el estándar RFC 3986 para reemplazar caracteres inseguros o ambiguos dentro de una URL con la forma %XX (donde XX es el valor hex del byte)
  • El kit de JavaScript tiene tres piezas: encodeURIComponent (nivel de componente, el default seguro), encodeURI (toda la URL pero preserva /?&=# caracteres sintácticos de URL — peligroso), y URLSearchParams (solo query strings)
  • Los 8 foot-guns comunes: doble codificación, más vs %20, caracteres reservados sin codificar, punto y coma dentro de segmentos de path (legal pero ambiguo), Unicode no pre-codificado, OAuth state haciendo round-trip por múltiples saltos, application/x-www-form-urlencoded mezclado con Content-Type application/json, comportamiento de decodificación por defecto del servidor varía por lenguaje
  • La dirección del fix es siempre la misma: codifica exactamente una vez con encodeURIComponent(value), codifica exactamente una vez a lo largo de toda la cadena, y nunca recodifiques algo que el salto anterior ya codificó
  • Usa Piick URL Encoder / Decoder para codificar o decodificar online, obtén la salida correcta para cada uno de los 3 modos y mantén tus datos locales. URLs de callback sensibles y tokens de API nunca salen del navegador

Los 8 foot-guns comunes

Foot-gun 1: Doble codificación (el %2520 no es un fallo de decodificación)

Síntoma: recibes un string como %2520, corres decodeURIComponent una vez y obtienes %20, lo corres una segunda vez y obtienes el verdadero (espacio). Tu primer pensamiento es «¿dónde decodifiqué una vez de más?».

Por qué sucede: un espacio codifica a %20 (cuatro caracteres: porcentaje, dos, cero). Esos cuatro caracteres luego son tratados como cuatro caracteres ordinarios %, 2, 0 y «codificados otra vez» por algún framework o por ti, produciendo %2520. Cada salto que codifica añade otra capa; si al menos un salto codifica redundante un valor que ya estaba codificado, obtienes doble codificación.

Fuentes comunes: framework de servidor que trata la URL completa como string plano y codifica de nuevo; o URLSearchParams del frontend que auto-codifica encima de encodeURIComponent escrito a mano; o redirects OAuth donde cada redirect re-codifica. Los tres son el mismo bug a distintas escalas.

Fix: codifica exactamente una vez a lo largo de toda la cadena. Regla práctica: el cliente (navegador) hace la codificación, el servidor hace la decodificación — y nunca al revés. Pega tu URL en el modo Full URL de Piick y detecta automáticamente la doble codificación mostrando un chip de advertencia, sin necesidad de contar %25 a ojo.

Escenario real: una callback de OAuth2 llega con state=abc%2525xyz, el servidor decodifica una vez y obtiene abc%25xyz, la capa de negocio compara con el original y los valores no coinciden, el flujo termina con state mismatch.

Foot-gun 2: Más vs %20 (la bifurcación histórica de form encoding)

Síntoma: el body de un POST de formulario tiene q=hello+world y el servidor dice «leo q como hello world, ¿por qué hay un +?». O al revés: el path de una URL es path=/hello world y el servidor lee path como /hello world (espacio no codificado).

Por qué sucede: cuando se envía un formulario HTML, si el method del formulario es GET o el enctype es application/x-www-form-urlencoded, el navegador codifica los espacios en los campos del formulario como + (NO %20). Es una convención heredada de HTML 4 que aplica solo a bodies de formularios y a las query strings de envíos GET de formularios. En paths de URL y componentes de URL, los espacios siempre codifican como %20. URLSearchParams es una tercera semántica más (trata el + como carácter literal, no como espacio). JS tiene tres reglas y mezclarlas es la forma más fácil de lanzar un bug.

Fix:

  • Los paths de URL y los valores de query parameter usan modo Component de Piick — siempre %20
  • Los bodies de formularios y los envíos GET de formularios usan modo Query de Piick+%20 automático
  • No mezcles los tres conjuntos de reglas, ni siquiera dentro de la misma app

Escenario real: escribes fetch('/api?q=' + userInput) donde userInput es hello world. La URL final es /api?q=hello world (espacio no codificado). El router del lado servidor encuentra un espacio, lanza URIError: URI malformed, toda la solicitud muere.

Foot-gun 3: Caracteres reservados sin codificar, el servidor divide campos mal

Síntoma: construyes ?q=foo&bar=baz pensando que vas a obtener un parámetro q=foo&bar=baz. El servidor realmente recibe dos parámetros, q=foo y bar=baz, y el segundo sobrescribe al primero.

Por qué sucede: el parser de URL ve & como delimitador entre parámetros sin importar nada. Así que q=foo&bar=baz son dos parámetros independientes. Para tratar & como carácter literal dentro del valor, debes codificarlo con encodeURIComponent, que te da %26.

Fix: siempre procesa los valores con encodeURIComponent(value). No hagas concatenación cruda de strings, no uses encodeURI (que preserva &), no te saltes la codificación.

Escenario real: un cliente GraphQL concatena una query en la URL — la query contiene una query de GraphQL con llaves y parámetros. Sin codificar, la URL se trunca en abc, el parser de GraphQL del backend lanza un error de sintaxis. El 99 % de estos bugs son este.

Foot-gun 4: Punto y coma y barras en segmentos de path, ambigüedad entre lenguajes

Síntoma: un diseño de path de URL RESTful /users/john;doe. El backend Spring de Java trata ;doe como parámetro matrix. Express de Node.js trata todo el string como un segmento plano. Python Flask hace algo más. La misma URL, tres lenguajes, tres resultados distintos.

Por qué sucede: RFC 3986 lista ; bajo sub-delims, legal dentro de paths, pero la semántica es ambigua — RFC la define como semántica OPCIONAL de parámetro matrix. / es el delimitador de segmentos, pero una vez codificado (volviéndose %2F) distintos frameworks siguen distintas convenciones. Múltiples comportamientos, sin consenso.

Fix: usa encodeURIComponent en cada valor dentro de cada segmento de path (no codifiques el segmento entero, solo los valores). Si debes usar parámetros matrix, bloquéate en un solo framework y documéntalo; no saltes entre lenguajes.

Escenario real: los paths tempranos de la API de Twitter contenían punto y coma (/statuses/show/:id.json;count=10). Los clientes Python y el SDK oficial de Java parseaban esos paths de forma diferente, y los ingenieros cross-lenguaje tenían una tarea diaria de debug.

Foot-gun 5: Unicode no pre-codificado, UTF-8 / GBK del servidor se enrede

Síntoma: una URL contiene chino como «用户搜索». El frontend envía la solicitud directamente. El Nginx del servidor más el backend devuelven «la query viene ilegible» o URIError: URI malformed.

Por qué sucede: encodeURIComponent("用户") produce %E7%94%A8%E6%88%B7 (secuencia de bytes UTF-8). Concatenar directamente caracteres no ASCII en una URL es una violación de RFC — RFC define un subconjunto ASCII más una extensión de escape por porcentaje pero no especifica cómo se transmiten los caracteres no ASCII. Incluso cuando Nginx está configurado con charset utf-8, maneja URLs a nivel de bytes y no adivina conjuntos de caracteres por ti.

Fix:

  • Frontend: siempre encodeURIComponent(value) antes de concatenar. Produce secuencias UTF-8 naturalmente.
  • Lado servidor: NO intentes «auto-detectar conjunto de caracteres». Establece que los clientes deben codificar primero.
  • Mientras testeas: abre DevTools, mira la pestaña Network. Si el request line ya es %E7%94..., vas bien.

Escenario real: un usuario overseas busca «手机» en un sitio de e-commerce chino. El frontend no codifica, el servidor intenta decodificar GBK y obtiene basura, los resultados de búsqueda no coinciden con la búsqueda, la tasa de conversión cae a la mitad.

Foot-gun 6: state de OAuth haciendo round-trip por múltiples saltos, defensa CSRF rota

Síntoma: la callback de OAuth 2.0 llega con state. Localmente codificas y pasas al servidor. El servidor codifica de nuevo o codifica implícitamente. Cuando el redirect llega al endpoint final, state no coincide con el valor original y el flujo termina.

Por qué sucede: el parámetro state de OAuth está diseñado para ser la identidad round-trip del request originante (defensa CSRF). Tiene que viajar generar local → codificar → URL → cruzar red → servidor → decodificar → comparar. Distintas librerías de OAuth manejan los defaults de encode/decode de forma diferente (Auth0 codifica por default, NextAuth no, Spring Security hace otra cosa), y las colaboraciones cross-lenguaje cross-librería se rompen.

Fix:

  • Una vez que te comprometes con una librería OAuth, usa la convención de codificación que recomienda. NO apiles encodeURIComponent manual encima.
  • Auto-test: codifica local → construye URL → servidor decodifica → compara → debe coincidir
  • Usa el modo Query de Piick URL Encoder / Decoder para comparar raw y decoded en ambos extremos

Escenario real: cuando se integra Notion o Google OAuth, los bugs de mismatch del state por default aparecen 3-5 veces en una semana. La causa común es que el desarrollador añade su propio encodeURIComponent encima de lo que la librería ya hace.

Foot-gun 7: application/x-www-form-urlencoded mezclado con Content-Type JSON

Síntoma: dices «mi body de POST es JSON» pero fetch añade Content-Type: application/x-www-form-urlencoded (o al revés, el body está en formato urlencoded pero el Content-Type dice JSON), y el servidor rechaza o parsea mal.

Por qué sucede: el Content-Type application/x-www-form-urlencoded fuerza al body a pasar por el parser urlencoded, que trata cada llave de apertura, llave de cierre, dos puntos y comillas en el body como carácter ilegal o carácter literal. A la inversa, el parser de application/json espera que el body sea JSON válido, y al ver formato urlencoded lanza SyntaxError.

Fix: el Content-Type debe coincidir con el formato real del body. JSON usa application/json y el body es JSON real. Formularios usan application/x-www-form-urlencoded y el body es key=value&key2=value2.

Escenario real: trampa clásica de debug de webhook — Stripe envía un body JSON de webhook, copias el comando curl y cambias el Content-Type a urlencoded, el servidor parsea el string JSON como parámetros literales, cada campo vuelve null.

Foot-gun 8: El comportamiento de decodificación por defecto del servidor es inconsistente entre lenguajes

Síntoma: tu código decodeURIComponent(req.url) explota en Node.js Express con URIError: URI malformed. Crees que el servidor tiene un bug, pero el servidor ya decodificó antes de que llegaras.

Por qué sucede:

  • Node.js / Nginx / Apache / Go net/http / Spring / varios frameworks de servidor toman decisiones distintas sobre si pre-decodifican URLs
  • La convención usual: el path ya fue decodificado una vez, el query ya fue decodificado una vez (implícito)
  • Express NO decodifica por default. Entonces cuando escribes decodeURIComponent, req.url ya fue decodificado una vez y obtienes URIError.
  • Pero req.originalUrl muestra raw, no puedes compararlo directamente.

Fix:

  • Lee los docs oficiales, averigua el comportamiento de decodificación por defecto del framework
  • Usa el modo Component de Piick: una codificación, una decodificación, sin doble entre medio
  • Cuando el stack está en capas (Express + Nginx + URL rewriter), revisa el access.log y el debug.log buscando la posición del %20 — eso señala qué capa introdujo una codificación extra

Escenario real: una app de e-commerce con tres capas (Nginx + Express + ORM) recibe reportes de bug «los parámetros de solicitud a veces se parsean mal». La causa raíz es que el framework ORM decodifica una vez internamente, Nginx decodifica una vez, Express decodifica otra vez — tres decodificaciones totales, los datos están enmarañados.

Decisiones de selección de herramienta

Tres rutas principales para codificación/decodificación de URL, cada una encaja en un escenario distinto:

Herramienta online en el navegador (este blog / esta herramienta)

Fortalezas: cero instalación, cero upload, amigable para URLs de callback sensibles y tokens de API. Tres modos (Component / Query / Full URL) auto-determinan las reglas de codificación correctas, la auto-detección de doble codificación muestra chips de advertencia. Encaja en debug de una vez, triage de incidentes en producción, troubleshooting de state de OAuth. Piick URL Encoder / Decoder es esta ruta — pruébala.

Funciones nativas de Node.js / navegador (en tu código)

  • encodeURIComponent(str) + decodeURIComponent(str) — nivel de componente, el default diario
  • encodeURI(str) + decodeURI(str) — toda la URL, preserva /?&=# caracteres sintácticos de URL. A menos que sepas exactamente qué estás haciendo, no uses esto.
  • new URL(str) — parsea una URL completa, muta partes y luego .toString()
  • URLSearchParams — solo query strings. NO trata el + raw como espacio (el inverso de form encoding)

Encaja en pipelines de build, tests unitarios, automatización. El footer de la página de la herramienta Piick tiene la tabla completa de reglas de RFC 3986 para cross-referenciar.

Middleware del framework del servidor (Express / Spring / Rails integrado)

El framework maneja el round-trip rutinario para que no tengas que hacerlo. Desventaja: edge cases (query con ;, espacios, Unicode) el framework no puede ayudar; aún necesitas un decodeURIComponent manual de fallback. Encaja en apps web CRUD, no recomendado en escenarios de alta seguridad o alta complejidad.

Recomendación de workflow cross-tool: debug con una herramienta de navegador → escribe código con encodeURIComponent → corre tests unitarios de round-trip en CI → antes del webhook signing, corre la URL canónica por el modo Full URL de Piick, luego encadena en el validador de firma de webhook para verificar que las firmas coincidan.

5 escenarios del mundo real

Escenario 1: Mismatch de state en callback de OAuth2

Anti-patrón: codifica local, luego el servidor codifica automáticamente de nuevo, el round-trip falla. Fix: bloquéate en el comportamiento de encoding de la librería OAuth, no apiles encoding manual encima. Verifica el round-trip comparando el resultado decodificado del extremo local contra el resultado decodificado del servidor en el modo Query de Piick.

Escenario 2: URL de webhook con query, normaliza antes de firmar

Preparar una firma de webhook (Stripe / GitHub / Slack y otros) típicamente usa un string canónico: normaliza la URL (strip host, ordena las keys del query, URL encode, luego body hash, finalmente HMAC con el secret). En esta cadena, la codificación de URL pasa exactamente una vez, en el paso de string canónico. El modo Full URL de Piick te ayuda a ver exactamente cómo se ve el string codificado. Después de firmar, verifica con el validador de firma de webhook para confirmar que la firma coincida.

Escenario 3: Ves %20 en los logs pero es un tema de configuración

Cuando el access.log de Nginx muestra strings doblemente codificados como %2520 y %2526, la causa suele ser un salto en la cadena de redirects que codifica redundante. Ubícalo con grep + decodificación inversa:

Por ejemplo, haz grep en el access log por todas las entradas que contengan %25, luego haz grep sobre la cadena de redirect de esa [URL] específica (las primeras entradas) para ver en qué salto aparece primero %2520 — ese es el salto con el bug.

¿En qué salto aparece primero %2520? Ese es el salto con el bug.

Escenario 4: El frontend construye URL de búsqueda sin codificar, el servidor divide campos mal

Anti-patrón: fetch construye la URL de búsqueda mediante concatenación de plantilla de string donde userInput es foo & bar. La URL queda /api/search?q=foo & bar, el espacio no está codificado, & se trata como separador de parámetros. Fix: envuelve con encodeURIComponent(userInput). Esta es la causa raíz más común de tickets de «por qué mi query parameter está mal».

Escenario 5: Path de URL REST contiene caracteres especiales

Anti-patrón: GET /api/users/john doe (con espacio). El backend devuelve 404. Fix: encodeURIComponent('john doe') da john%20doe, la URL final es /api/users/john%20doe, el backend recibe «john doe».

Prácticas recomendadas

  • Siempre encodeURIComponent(value) para los valores. Nunca encodeURI, nunca te saltes la codificación.
  • Codifica exactamente una vez a lo largo de toda la cadena: el cliente (navegador) hace la codificación, el servidor hace la decodificación, nunca al revés. En escenarios multi-salto como OAuth o Webhooks, si cada salto re-codifica obtienes doble codificación. La regla es: codifica exactamente una vez a lo largo de toda la cadena.
  • Test de round-trip antes de producción: pega tu valor original en Piick, mira la salida codificada, pega esa salida en el ambiente de test, decodifica y verifica que obtienes el mismo valor. Este sanity check toma 5 segundos.
  • No mezcles los tres conjuntos de reglas: los componentes de URL usan modo Component, las query strings usan modo Query, los bodies de formularios usan urlencoded. No uses un conjunto del lado JS y otro del lado Python.
  • URLs de callback sensibles y tokens de API nunca salen del navegador: usa Piick URL Encoder / Decoder online, 100 % datos locales, nada subido.

URL encoding es infraestructura ineludible horneada en el protocolo HTTP, pero RFC 3986 te da el «qué» — el «cómo» lo decide tu stack. Después de suficiente debug vas a conocer las trampas de memoria, pero hasta entonces, bookmarca Piick URL Encoder / Decoder, ábrelo para un sanity check de 1 minuto cuando algo parezca estar mal, no pierdas tiempo.