Codificación de URL explicada: el porcentaje y dónde muerde
Publicado el 7/7/2026 · 17 min de lectura · Herramientas para desarrolladores
Daniel Okonkwo — Desarrollador front-end y redactor de Tecnología en OneKitly
Rendimiento web · Formatos de archivo
Verificado con 6 fuentes
La codificación por porcentaje sustituye un byte por un signo de porcentaje y dos dígitos hexadecimales. Qué bytes hay que sustituir depende de en qué parte de la URL estás, y por eso el tema parece incoherente. La RFC 3986 define caracteres no reservados que nunca necesitan codificación — A-Z, a-z, 0-9, guion, punto, guion bajo y virgulilla — y caracteres reservados con significado estructural: los gen-delims : / ? # [ ] @ y los sub-delims ! $ & ' ( ) * + , ; = . Un carácter reservado debe codificarse cuando aparece como dato y no como estructura. Así, / es perfectamente legal dentro de una ruta y debe convertirse en %2F dentro de un valor de consulta, porque allí se leería como parte de la ruta. JavaScript te da tres funciones que discrepan en esto. Ejecútalas sobre a b/c?d=café+e&f#g~h*i(j): encodeURI devuelve a%20b/c?d=caf%C3%A9+e&f#g~h*i(j), encodeURIComponent devuelve a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j), y la obsoleta escape devuelve a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29. Usa encodeURIComponent para cada valor individual, encodeURI solo para una URL entera en la que ya confías, y escape nunca — emite Latin-1, así que é sale %E9 en vez del correcto UTF-8 %C3%A9.
La codificación por porcentaje se decide componente a componente, y de ahí viene toda la confusión. Una barra es legal en una ruta y debe escaparse en un valor de consulta; un espacio es %20 en una ruta y puede ser + en un cuerpo de formulario. Aquí están los conjuntos exactos de la RFC 3986, las tres funciones de JavaScript que discrepan y las trampas.
La regla es por componente, no por URL
Una URL no es una sola cadena, es una secuencia de partes etiquetadas: esquema, host, ruta, consulta, fragmento. Cada parte tiene su propia idea de qué es estructura y qué es dato, y la codificación por porcentaje existe para distinguirlas. Una barra dentro de una ruta es estructura — separa segmentos —, así que se queda como está. Esa misma barra dentro de un valor de consulta es dato, y debe escribirse %2F; si no, un analizador que lea la consulta no tiene forma de saber que querías un carácter literal y no un trozo de ruta que acabó en el sitio equivocado.
La consecuencia aparece en cuanto construyes una URL por concatenación. Supón que un nombre de archivo es 2026/08 report.pdf y debe caber en un segmento de ruta. Codifica el valor y obtienes /files/2026%2F08%20report.pdf, un solo segmento como pretendías. Sáltate la codificación y obtienes /files/2026/08 report.pdf, tres segmentos y un espacio, apuntando a algo que no existe. La misma asimetría golpea los valores de consulta: ?note=rock&roll se analiza como dos parámetros, note con valor rock y un roll vacío, mientras que ?note=rock%26roll se analiza como el único valor que querías.
Reservados y no reservados, exactamente como los define la RFC 3986
El conjunto no reservado es pequeño y merece memorizarse: las letras de la A a la Z en ambas cajas, los dígitos del 0 al 9 y exactamente cuatro signos de puntuación — guion, punto, guion bajo y virgulilla. Esos nunca requieren codificación en ninguna parte de una URL, y codificarlos igualmente es legal pero inútil, ya que %41 y A denotan el mismo carácter y un analizador conforme los trata idénticamente.
El conjunto reservado se divide en dos. Los gen-delims son los caracteres que separan los componentes principales: dos puntos, barra, interrogación, almohadilla, corchetes de apertura y cierre, y arroba. Los sub-delims estructuran el interior de un componente: exclamación, dólar, ampersand, apóstrofo, paréntesis de apertura y cierre, asterisco, más, coma, punto y coma e igual. Todo lo que no esté en el conjunto no reservado ni en el reservado — caracteres de control, espacio, comillas rectas, ángulos, barra invertida, circunflejo, acento grave, llaves, barra vertical y todo byte por encima de 127 — debe codificarse siempre.
Un detalle pilla a la gente. encodeURIComponent deja intactos el signo de exclamación, el apóstrofo, los paréntesis y el asterisco, y los cuatro son sub-delims según la RFC 3986. Esos caracteres son legales donde la función se usa normalmente, así que es inocuo en los casos ordinarios; pero si produces un valor para un sistema que sigue la RFC 3986 estrictamente — algunos esquemas de firma y canonicalizaciones tipo OAuth lo hacen —, tienes que escaparlos tú después. Nota además que los corchetes siempre los escapan ambos codificadores de JavaScript, dando %5B y %5D, porque se añadieron al conjunto reservado para literales IPv6 después de que se especificaran las funciones.
Tres funciones de JavaScript sobre una misma cadena, y la excepción del signo más
Toma a b/c?d=café+e&f#g~h*i(j) y ejecuta las tres. encodeURI produce a%20b/c?d=caf%C3%A9+e&f#g~h*i(j): se codifican el espacio y la letra acentuada, todo lo estructural queda intacto. encodeURIComponent produce a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j): la barra, la interrogación, el igual, el más, el ampersand y la almohadilla se escapan todos, porque en el mundo de esta función la cadena entera es un único valor. escape produce a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29, distinto de ambos.
Enumera el rango ASCII y la diferencia se vuelve precisa. Las dos funciones modernas discrepan en exactamente once caracteres: # $ & + , / : ; = ? @ los deja intactos encodeURI y los escapa encodeURIComponent. Esa lista es el conjunto reservado, lo que te dice para qué sirve cada función. encodeURI supone que la cadena ya es una URL completa cuyos delimitadores deben sobrevivir; encodeURIComponent supone que la cadena es un valor único al que no se le puede permitir introducir ningún delimitador.
escape es otro animal y no debería usarse nunca. Es anterior a las especificaciones modernas y codifica a Latin-1 en vez de UTF-8: é sale %E9 en lugar del correcto %C3%A9, y todo lo que pase de U+00FF sale como una secuencia no estándar %uXXXX, de modo que el signo del euro sale como %u20AC. Además deja sin escapar el más, la arroba y la barra, todos peligrosos dentro de un valor de consulta, mientras escapa innecesariamente la virgulilla y los paréntesis. Sobrevive en el lenguaje solo por compatibilidad hacia atrás, en el anexo reservado a las funcionalidades que existen pero en las que no hay que apoyarse.
La codificación por porcentaje tal como la define la RFC 3986 tiene exactamente una representación del espacio: %20. Funciona en todas partes — ruta, consulta, fragmento. El signo más como espacio pertenece a otro mecanismo, más antiguo: la serialización application/x-www-form-urlencoded que usan los formularios HTML, en la que los espacios pasan a signos más y un más literal debe pasar a %2B. Los navegadores usan esa forma para la cadena de consulta de un envío de formulario por GET, y por eso ves ambas convenciones en cadenas de consulta reales.
La consecuencia es un fallo de decodificación fácil de escribir y difícil de ver. decodeURIComponent("a+b") devuelve a+b, con el más intacto, porque decodeURIComponent implementa la RFC 3986 y no sabe nada de codificación de formularios. Dale la misma cadena a un analizador consciente de formularios y obtienes a b. Así que el decodificador correcto depende de cómo se produjo la cadena. En la práctica lo fiable es dejar de improvisar en ambos lados: construye las cadenas de consulta con URLSearchParams, que serializa un espacio como + y escapa un más literal como %2B, y léelas con URLSearchParams, que invierte exactamente las mismas reglas.
Lo no ASCII pasa primero por UTF-8
La codificación por porcentaje opera sobre bytes, no sobre caracteres, así que un carácter no ASCII debe convertirse en bytes antes de poder escaparse. La regla moderna es UTF-8 y luego un escape por byte. é es un solo carácter codificado como los dos bytes c3 a9, así que sale %C3%A9. El signo del euro son tres bytes, e2 82 ac, así que sale %E2%82%AC — nueve caracteres para un símbolo. Un emoji como U+1F600 son cuatro bytes y sale %F0%9F%98%80, doce caracteres.
Aquí es donde escape te traiciona, ya que manda esa misma é al único byte %E9, su punto de código Latin-1. Un servidor que decodifica como UTF-8 ve una secuencia de bytes inválida y o bien lanza un error o bien produce un carácter de reemplazo, y el fallo solo aparece en las filas acentuadas de tus datos. La RFC 3986 no impone por sí misma una codificación de caracteres — es anterior a la adopción universal de UTF-8 y solo lo recomienda para esquemas nuevos —, pero el estándar URL del WHATWG, que es lo que los navegadores implementan de verdad, especifica UTF-8 en todas partes. Trata UTF-8 como la única respuesta correcta.
La doble codificación y cómo aparece %2520
El signo de porcentaje es él mismo un carácter reservado, así que codificar una cadena ya codificada escapa los escapes. Parte de a b. Codifica una vez: a%20b. Codifica eso: a%2520b, porque el porcentaje pasó a %25. Codifica otra vez: a%252520b. Cada vuelta añade tres caracteres y una pasada de decodificación obligatoria más, y la cadena crece sin lanzar nunca un error.
En sistemas reales esto ocurre cuando un valor cruza varias capas y cada una codifica servicialmente lo que le entregaron: un cliente codifica, una pasarela vuelve a codificar, un framework codifica al entrar en una plantilla. El síntoma es un %20 literal apareciendo en una página o en un nombre de archivo donde debería haber un espacio, o un 404 en una ruta que parece correcta. La cura es una disciplina, no un truco: decide exactamente un lugar del pipeline que sea dueño de la codificación, codifica ahí y pasa valores en bruto en todos los demás sitios. Si debes decodificar a la defensiva, decodifica una vez y comprueba si el resultado aún contiene un porcentaje seguido de dos dígitos hexadecimales antes de decidir decodificar de nuevo — y ten en cuenta que decodificar repetidamente a ciegas es en sí un problema de seguridad, porque puede convertir %252e%252e en .. y reabrir un recorrido de rutas que una sola decodificación había cerrado.
El host es otro mecanismo: IDN y punycode
La codificación por porcentaje no se aplica al nombre de dominio. Las etiquetas DNS se limitan a letras, dígitos y guiones, así que los dominios internacionalizados usan una transformación completamente aparte: Punycode, definido en la RFC 3492 y envuelto por las especificaciones IDNA. münchen.de pasa a xn--mnchen-3ya.de, bücher.example pasa a xn--bcher-kva.example, y un dominio japonés como los dos caracteres de Japón seguidos de .jp pasa a xn--wgv71a.jp. El prefijo xn-- marca la etiqueta como codificada; lo que sigue son los caracteres ASCII en orden, luego un separador y luego instrucciones para reinsertar los no ASCII.
Puedes ver los dos mecanismos trabajando en paralelo. Analiza https://münchen.de/straße?q=über alles con un analizador de URL estándar y el resultado es https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles: el host pasó por Punycode, la ruta y la consulta por codificación UTF-8 con porcentaje, y ninguno pisó el terreno del otro. Esta separación no es cosmética. Es la razón de que una secuencia con porcentaje en un nombre de host no se decodifique como cabría esperar, y de que los ataques por homógrafo — registrar un dominio cuyos caracteres Unicode se parecen a los de otro — sean un problema de la capa Punycode, que los navegadores abordan con reglas de visualización y no con codificación.
Cuatro reglas que evitan casi todos los problemas
Primero, nunca montes una URL por concatenación de cadenas cuando hay un constructor de URL disponible. new URL() y URLSearchParams saben en qué componente están y codifican en consecuencia, que es justo el conocimiento que le falta a una plantilla de cadena. Segundo, codifica valores, no URLs: aplica encodeURIComponent a cada segmento de ruta y a cada valor de consulta, y reserva encodeURI para una URL terminada que hayas construido tú. Tercero, codifica una sola vez, en una única capa dueña, y pasa valores en bruto en todos los demás sitios — eso solo elimina toda la clase de fallos con %2520. Cuarto, borra escape de tu base de código; codifica en Latin-1 y deja pasar caracteres peligrosos, y no hay situación en la que sea la respuesta correcta.
| Codificador | Salida sobre a b/c?d=café+e&f#g~h*i(j) | Deja intactos (más allá de letras y dígitos) | No ASCII | Úsalo para |
|---|---|---|---|---|
| encodeURIComponent | a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j) | - . _ ~ ! ' ( ) * | UTF-8 y luego porcentaje | Cada valor individual: un segmento de ruta, un valor de consulta, un fragmento |
| encodeURI | a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) | Todo lo que conserva el codificador de componente, más # $ & + , / : ; = ? @ | UTF-8 y luego porcentaje | Una URL entera que montaste tú y en la que ya confías |
| escape (obsoleta) | a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29 | * + - . / @ _ — y escapa ~ ( ) que los demás conservan | Latin-1 hasta U+00FF, luego el no estándar %uXXXX | Nada. Está en el estándar solo por compatibilidad hacia atrás |
| URLSearchParams (codificación de formulario) | q=a+b para el valor a b; a/b+c pasa a a%2Fb%2Bc | Mismo conjunto no reservado, pero un espacio pasa a + y no a %20 | UTF-8 y luego porcentaje | Construir una cadena de consulta o un cuerpo x-www-form-urlencoded |
Preguntas frecuentes
- ¿Cuál es la diferencia entre encodeURI y encodeURIComponent?
- Exactamente once caracteres. encodeURI deja intactos # $ & + , / : ; = ? @; encodeURIComponent los escapa todos. Esa lista es el conjunto reservado de la RFC 3986, y te dice qué supone cada función. encodeURI cree que le entregaste una URL completa cuyos delimitadores deben seguir funcionando, así que solo escapa lo que nunca podría ser estructura — espacios, no ASCII, caracteres de control. encodeURIComponent cree que le entregaste un valor único que no debe poder introducir ningún delimitador, así que escapa todo lo reservado. Sobre la cadena a b/c?d=café+e&f#g~h*i(j) el primero devuelve a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) y el segundo devuelve a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j). Usa encodeURIComponent para cada segmento de ruta y cada valor de consulta, que es casi siempre lo que quieres. Usa encodeURI solo sobre una URL entera que montaste tú y en la que ya confías — aplicarlo a entrada de usuario no vuelve segura esa entrada, porque preserva a propósito los caracteres que un valor inyectado necesitaría.
- ¿Un espacio debe ser %20 o un signo más?
- %20 siempre es correcto; el signo más solo lo es en un contexto concreto. La codificación por porcentaje de la RFC 3986 tiene una sola representación del espacio, %20, válida por igual en la ruta, la consulta y el fragmento. El más como espacio viene de application/x-www-form-urlencoded, la serialización más antigua que usan los formularios HTML, donde los espacios pasan a más y un más literal debe escribirse %2B. Los navegadores lo aplican al enviar un formulario por GET, y por eso las cadenas de consulta reales contienen ambas convenciones. El peligro práctico está en la decodificación: decodeURIComponent("a+b") devuelve a+b con el más intacto, porque implementa la RFC 3986 y no sabe de formularios, mientras que un analizador consciente de formularios devuelve a b. No decidas caso por caso. Construye las cadenas de consulta con URLSearchParams y léelas con URLSearchParams, para que se apliquen las mismas reglas en ambos sentidos. En un segmento de ruta usa siempre %20 — un más ahí es un carácter más literal y nada más.
- ¿Por qué veo %2520 en mis URL?
- Porque algo codificó una cadena que ya estaba codificada. El signo de porcentaje es él mismo reservado, así que pasa a %25 al escaparse. Toma a b, codifícalo a a%20b y luego codifica ese resultado: el porcentaje se convierte en %25 y sale a%2520b. Hazlo otra vez y sale a%252520b. Nada falla, la cadena solo crece tres caracteres por vuelta y necesita una pasada de decodificación extra. En sistemas reales esto pasa cuando varias capas codifican educadamente cada una lo que le entregaron — un cliente, luego una pasarela, luego un framework al renderizar en una plantilla. El síntoma es un %20 literal donde debería haber un espacio, o un 404 en una ruta que parece correcta. El arreglo es arquitectónico: nombra exactamente una capa dueña de la codificación, codifica ahí y mueve valores en bruto en todos los demás sitios. Evita decodificar en bucle hasta que no queden porcentajes, porque eso es una vulnerabilidad en sí — la decodificación repetida puede convertir %252e%252e en .. y reabrir un recorrido de rutas que una sola decodificación había contenido.
- ¿Hay que codificar los caracteres no ingleses en una URL?
- Sí, y la codificación pasa primero por UTF-8. La codificación por porcentaje trabaja sobre bytes, así que un carácter debe convertirse en bytes antes de escaparse, y la regla moderna es codificar como UTF-8 y luego escribir un escape por byte. é son dos bytes, c3 a9, y sale %C3%A9. El signo del euro son tres bytes y sale %E2%82%AC, nueve caracteres para un símbolo. Un emoji en U+1F600 son cuatro bytes y sale %F0%9F%98%80. Esto importa para los tamaños de almacenamiento y para cualquier límite de longitud que impongas, ya que un carácter puede costar doce. También explica por qué la función obsoleta escape corrompe datos: emite Latin-1, convirtiendo é en el único byte %E9, que un decodificador UTF-8 rechaza como inválido — y la rotura solo aparece en tus registros acentuados. El host es la excepción: los nombres de dominio no se codifican por porcentaje sino que se convierten con Punycode, de modo que münchen.de pasa a xn--mnchen-3ya.de mientras la ruta y la consulta a su lado usan la codificación UTF-8 corriente.
- ¿Basta la codificación por porcentaje para que la entrada del usuario sea segura?
- No, porque la codificación es contextual y una URL es solo uno de los contextos por los que pasa un valor. encodeURIComponent impide que un valor se escape de su componente de URL: una barra pasa a %2F y no puede abrir un segmento de ruta, un ampersand pasa a %26 y no puede abrir un parámetro. Eso es real e importante. No hace nada por el siguiente salto. Ese mismo valor puesto en HTML necesita escape HTML, puesto en una sentencia SQL necesita consulta parametrizada, puesto en un comando de shell necesita entrecomillado a nivel de argumento, y puesto en un literal de cadena de JavaScript necesita su propio escape. Codificar para el contexto equivocado no es protección parcial: es ninguna protección. Dos avisos más: encodeURI no es un saneador de entrada, ya que preserva a propósito los caracteres reservados que usaría un atacante, así que nunca lo apliques a datos no fiables como medida de seguridad; y decodificar repetidamente hasta que no quede porcentaje puede reconstruir secuencias que una sola decodificación había neutralizado, en particular devolver %252e%252e a un recorrido de rutas.
- ¿Cómo encajan aquí los nombres de dominio internacionalizados?
- No usan codificación por porcentaje en absoluto. Las etiquetas DNS se limitan a letras, dígitos y guiones, así que un dominio con cualquier otra cosa se convierte con Punycode, definido en la RFC 3492 y regido por las especificaciones IDNA. münchen.de pasa a xn--mnchen-3ya.de, bücher.example pasa a xn--bcher-kva.example, y un dominio japonés como los dos caracteres de Japón seguidos de .jp pasa a xn--wgv71a.jp. El prefijo xn-- marca la etiqueta como codificada, y el resto contiene los caracteres ASCII seguidos de instrucciones para devolver los demás. Puedes ver ambos sistemas a la vez analizando una URL como https://münchen.de/straße?q=über alles: el resultado es https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles, con Punycode en el host y codificación UTF-8 con porcentaje en todo lo que sigue. En la práctica, nunca codifiques por porcentaje un nombre de host y compara los nombres de host en su forma Punycode. Es también la razón de que los ataques por homógrafo se traten con la política de visualización del navegador y no con codificación — los dos nombres son de verdad etiquetas distintas que solo se parecen.
Artículos que podrían interesarte
Todas las guías →Herramientas relacionadas
Fuentes
- IETF — RFC 3986, Uniform Resource Identifier (URI): Generic Syntax
- WHATWG — URL Standard
- WHATWG — HTML Standard, URL-encoded form data
- IETF — RFC 3492, Punycode: A Bootstring encoding of Unicode for IDNA
- IETF — RFC 5890, Internationalized Domain Names for Applications (IDNA): Definitions and Document Framework
- MDN Web Docs — encodeURIComponent()
¿Has detectado un error en este artículo?