¿Por qué se rompe mi codificación de URL? Percent-encoding y + vs %20
Pones café en una URL y llega como café. Un espacio es + en un sitio y %20 en otro. Un & dentro de un valor parte tu query en dos. Algo se codificó dos veces y ahora lees %2520. Todos parecen bugs distintos, pero son una idea con dos vueltas de tuerca: el percent-encoding escapa bytes que una URL no puede escribir tal cual, las reglas cambian por componente, y un espacio tiene dos codificaciones legales.
Pones café en una URL y sale por el otro lado como café. Codificas un espacio y obtienes un + en un sitio, un %20 en otro, y ya no sabes cuál es correcto. Un valor con un & dentro parte tu query en dos y la segunda mitad desaparece sin ruido. Algo se codificó dos veces y estás mirando un %2520. Cada uno parece su propio bug, así que parcheas cada uno con su propio apaño —decodifica aquí, .replace() allá, codifica una vez más— y algo se rompe una capa más abajo.
Aquí está el hecho que hay debajo de todos: el percent-encoding es cómo una URL transporta los bytes que no se le permite escribir tal cual. La forma serializada de una URL usa un conjunto restringido de caracteres ASCII, y cualquier cosa fuera de él —texto no-ASCII, o un carácter con un trabajo estructural como & o / cuando aparece dentro de un valor— debe escaparse como un % seguido de los dos dígitos hexadecimales de cada byte. Ese es todo el mecanismo. La confusión viene de dos vueltas de tuerca encima: las reglas de qué caracteres deben escaparse cambian según en qué parte de la URL estés, y un espacio tiene dos codificaciones legales —%20 y +— que significan lo mismo en exactamente un contexto y cosas distintas en todos los demás.
Este artículo dedica un minuto a qué es el percent-encoding, y luego recorre las pocas formas en que se tuerce: la división +/%20, codificar una URL entera cuando querías una pieza, el paso UTF-8 que convierte café en %C3%A9 (o café cuando se maneja mal), y la doble codificación que vuelve %20 en %2520. Al terminar tendrás una lista para cualquier URL que salga estropeada.
La única idea: escapar un byte como % más sus dos dígitos hex
La forma serializada de una URL solo puede contener un conjunto limitado de caracteres ASCII —así que el texto no-ASCII hay que convertirlo en bytes (con UTF-8) y escaparlo antes de que pueda viajar—. El percent-encoding —alias codificación URL— es la vía de escape: toma cualquier byte que no esté permitido aquí y escríbelo como % seguido del valor de ese byte en dos dígitos hexadecimales. Un espacio es el byte 0x20, así que se vuelve %20; el / es el byte 0x2F, así que cuando es dato y no separador de ruta se vuelve %2F. Dos familias de caracteres fijan las reglas:
- No reservados —
A–Z a–z 0–9 - . _ ~— siempre seguros, nunca necesitan codificación. - Reservados —
: / ? # [ ] @ ! $ & ' ( ) * + , ; =— llevan significado estructural: separan la cadena de consulta (query string) de la ruta, un parámetro del siguiente, una clave de su valor. Son legales como delimitadores, pero cuando uno aparece dentro de un valor, debe codificarse —o se leerá como estructura.
Ese segundo punto es todo el bug de «el & partió mi query» en una frase: un & sin codificar dentro de un valor es indistinguible del & entre dos parámetros, así que todo lo que va después se interpreta como un parámetro nuevo.
+ frente a %20: las dos caras de un espacio
Es lo más confuso del percent-encoding: ambos son correctos, en sitios distintos. Un espacio tiene dos codificaciones, y cuál es la correcta depende del componente:
- En la ruta y en la mayor parte de una URL, un espacio es
%20. Un+literal ahí es solo un signo más. - En datos codificados como formulario (
application/x-www-form-urlencoded), un espacio es+, y un más literal debe escribirse%2B.
Así que en los componentes de URL en general %20 es la forma inequívoca de escribir un espacio, mientras que «+ significa espacio» es la convención de application/x-www-form-urlencoded —la regla que siguen los codificadores de formularios, visible sobre todo en la query pero también en el cuerpo de una petición de formulario—. Los bugs viven en esa frontera: un decodificador que trata + como espacio en la ruta corrompe un más real, y uno que no trata + como espacio en datos codificados como formulario te deja signos + literales donde iban espacios. Puedes ver divergir las dos convenciones con la misma entrada:
encodeURIComponent("a b") // "a%20b"
new URLSearchParams({ q: "a b" }).toString() // "q=a+b"
encodeURIComponent apunta a componentes de URL en general, así que emite %20; URLSearchParams serializa como formulario, así que emite +. Es también justo la trampa que alcanza de vuelta a Base64 —una cadena Base64 transportada en datos codificados como formulario puede tener sus + convertidos en espacios en silencio por la decodificación de formulario, que es un bug de «¿por qué no decodifica mi Base64?» que en realidad es esta regla +/espacio disfrazada—. En la duda, codifica los espacios como %20 y los más como %2B, y la ambigüedad desaparece.
Codifica la pieza, no la URL entera
Una porción enorme de «codificación rota» es codificar con la granularidad equivocada. Hay dos trabajos y dos herramientas distintas:
- Codificar una URL entera deja en paz los caracteres estructurales (
: / ? # & =), porque están haciendo su trabajo. En JavaScript esencodeURI. - Codificar un componente —un solo valor de query, un segmento de ruta— debe escapar todo lo reservado, incluidos
/ ? # & =, porque aquí son datos, no estructura. Ese esencodeURIComponent.
Pasa el codificador de URL entera sobre un valor y sus & y = cruzan sin escapar y hacen estallar tu query. Pasa el codificador de componente sobre una URL entera y sus :// y ? se escapan en un galimatías que ya no enruta. La regla: construye la URL a partir de componentes codificados; nunca codifiques la URL terminada como una sola cadena. Codifica cada valor con el codificador de componente y luego únelos con los ?, & y = literales que quieres que sigan siendo estructura.
No-ASCII: primero UTF-8, luego percent-encoding
café no se vuelve %café. El percent-encoding actúa sobre bytes, y un carácter como é no es un byte —así que hay un paso oculto: el texto se codifica primero a bytes con UTF-8, y luego cada byte se percent-codifica. é son los dos bytes UTF-8 0xC3 0xA9, así que se vuelve %C3%A9, y café se vuelve caf%C3%A9. Un carácter CJK suele ser tres bytes, de ahí tres grupos %XX. Por eso exactamente café se convierte en café: los bytes se codificaron como UTF-8 pero algo más abajo los decodificó como Latin-1, donde 0xC3 0xA9 se lee é. El mojibake en una URL casi siempre es un desajuste UTF-8-contra-otra-cosa, no un fallo del percent-encoding. Y fíjate en que esos pares %XX son solo hexadecimal —%C3 es el valor de byte 0xC3—, que es por qué leer hex con soltura vuelve legible de golpe una URL codificada.
Una cosa más que conviene separar: la parte de host de una URL no usa percent-encoding para el no-ASCII. café.com se vuelve xn--caf-dma.com mediante Punycode / IDN, un mecanismo distinto por completo —así que un dominio no-ASCII y una ruta no-ASCII se escapan con dos sistemas diferentes, y confundirlos es su propia fuente de líos.
Doble codificación: cómo %20 se vuelve %2520
El lío clásico de aguas abajo. El percent-encoding no es idempotente: codifica una cadena ya codificada y los propios signos % se codifican, porque % es el byte 0x25 → %25. Así que %20 (un espacio codificado) pasado por el codificador una segunda vez se vuelve %2520, y quien lee ve ahora un %20 literal en el texto en vez de un espacio. La señal es un %25 que aparece donde no lo pusiste —%2520, %253A, %2526—. Ocurre cuando un valor lo codifica tu código y luego lo codifica otra vez un framework, un cliente HTTP o una redirección que supuso que el valor seguía en bruto. El arreglo es codificar exactamente una vez: localiza la capa que envuelve dos veces y deja que solo una haga el trabajo. Para ver las capas, pega la cadena en un codificador/decodificador de URL y decodifícala repetidamente —cada pasada pela una capa, y cuando los %25 se vuelven % ya sabes cuántas veces se envolvió—. Decodificar en bucle es aquí un diagnóstico, no un arreglo: no lo hagas a ciegas en producción, porque un único valor bien codificado puede contener de forma legítima texto literal %25 o %20 propio.
Los rincones que aún muerden
Algunos detalles menores que se esconden en las especificaciones:
encodeURIComponentno codifica! ' ( ) *. Algunos servidores, siguiendo el RFC 3986 al pie, quieren esos escapados también —así que si un endpoint quisquilloso los rechaza, codifícalos a mano.- Un
+en una ruta es un más literal. Solo los datosapplication/x-www-form-urlencodedleen+como espacio, así que no «arregles» una ruta convirtiendo+en espacio. #trunca en silencio. Un#sin codificar dentro de un valor inicia el fragmento de la URL, y todo lo que va después nunca llega al servidor —un#en los datos debe ser%23.
La lista para una codificación de URL estropeada
Cuando una URL sale mal, no empieces a reemplazar caracteres al azar. Pregunta esto en orden:
- ¿Qué componente? Una URL entera mantiene
: / ? # & =como estructura; un solo valor debe escaparlos. Usa el codificador de componente para valores (encodeURIComponent), el de URL para URLs enteras (encodeURI). - ¿El espacio aparece como
+o%20? En datosapplication/x-www-form-urlencoded,+significa un espacio; los demás componentes de URL usan%20. Para ir seguro, codifica espacios como%20y el más como%2B. - ¿Acentos o CJK corruptos? Es un desajuste UTF-8-contra-charset, no percent-encoding —haz que ambos extremos acuerden UTF-8—. Los nombres de host usan Punycode, no
%XX. - ¿Ves un
%25que no pusiste? Está doblemente codificado —decodifica hasta que los%25se vuelvan%, y luego codifica exactamente una vez. - ¿Un valor se corta o la query se parte? Un
#,&o=sin codificar dentro de un valor se está leyendo como estructura —codifícalo.
Bajo los cinco está la única idea: el percent-encoding escapa un byte como % más dos dígitos hex, y lo único difícil es distinguir dato de estructura, y el único contexto donde un espacio es +. Nombra en cuál de esos estás del lado equivocado, y la URL que parecía estropeada se resuelve limpiamente.