Por qué las URLs necesitan codificación
Una URL es texto con estructura: algunos caracteres son datos y otros son delimitadores que le dicen al navegador y al servidor dónde termina una parte y empieza la siguiente. El problema aparece cuando un dato contiene uno de esos delimitadores (un & en el nombre de un producto, una / en un parámetro de búsqueda) o un carácter que directamente no puede ir en una URL, como un espacio o una letra con acento. La codificación porcentual resuelve las dos situaciones con el mismo mecanismo. Esta guía explica qué dice el estándar, qué cambia en cada parte de la URL y por qué la mayoría de los errores vienen de codificar demasiado poco, demasiado, o con la función equivocada. Para codificar o decodificar un valor concreto, el codificador de URL lo hace en el navegador.
La anatomía de una URL
El RFC 3986 define la sintaxis genérica de las URI. Tomando como ejemplo https://ana@tienda.ejemplo.com:8443/productos/café?color=rojo&talla=M#reseñas:
| Parte | Valor en el ejemplo | Delimitador |
|---|---|---|
| Esquema | https | termina en : |
| Usuario | ana | termina en @ |
| Host | tienda.ejemplo.com | empieza después de // |
| Puerto | 8443 | empieza en : |
| Ruta | /productos/café | segmentos separados por / |
| Query | color=rojo&talla=M | empieza en ? |
| Fragmento | reseñas | empieza en # |
Esa URL, tal como está escrita, no es válida según el RFC: café y reseñas contienen caracteres fuera de ASCII. Lo que realmente se envía es /productos/caf%C3%A9 y #rese%C3%B1as. Los navegadores muestran la versión legible en la barra de direcciones, pero por la red viaja la codificada.
Caracteres reservados y no reservados
El RFC 3986 divide los caracteres permitidos en dos grupos:
| Grupo | Caracteres | Regla |
|---|---|---|
| No reservados | A–Z a–z 0–9 - . _ ~ | Nunca necesitan codificación |
| Delimitadores generales | : / ? # [ ] @ | Separan las partes de la URL |
| Subdelimitadores | ! $ & ' ( ) * + , ; = | Separan datos dentro de una parte (por ejemplo, & y = en la query) |
La regla práctica es: un carácter reservado que se usa como dato, y no como delimitador, tiene que codificarse. Todo lo que está fuera de estos tres grupos (espacios, acentos, comillas, <, >, % como dato) se codifica siempre.
Cómo se codifica un carácter
Primero se convierte el carácter a bytes con UTF-8, que es lo que recomienda el RFC 3986 para cualquier texto nuevo. Después, cada byte se escribe como % seguido de dos dígitos hexadecimales, en mayúsculas según la recomendación del estándar:
| Carácter | Bytes UTF-8 | Codificado |
|---|---|---|
| espacio | 20 | %20 |
& | 26 | %26 |
é | C3 A9 | %C3%A9 |
ñ | C3 B1 | %C3%B1 |
€ | E2 82 AC | %E2%82%AC |
🙂 | F0 9F 99 82 | %F0%9F%99%82 |
Así, café se convierte en caf%C3%A9. El host es la excepción: los nombres de dominio con caracteres no ASCII usan Punycode en lugar de codificación porcentual, y españa.es se resuelve como xn--espaa-rta.es.
Cada parte tiene sus propias reglas
Ruta. La / separa segmentos, así que si un segmento contiene una barra como dato (el nombre de archivo informe 1/2.pdf), hay que codificarla como %2F. El + en la ruta es un signo más literal, no un espacio. Los espacios van como %20.
Query string. Por convención, los parámetros se escriben como nombre=valor separados por &, así que dentro de un nombre o un valor hay que codificar &, =, # y +. El RFC permite / y ? sin codificar dentro de la query, pero codificarlos nunca rompe nada. Aquí aparece la gran confusión del espacio: los formularios HTML usan el formato application/x-www-form-urlencoded, donde el espacio se escribe +, mientras que el RFC 3986 lo escribe %20. En la query, casi todos los servidores aceptan las dos formas; por eso un + que sea dato tiene que ir como %2B, o se leerá como espacio.
Fragmento. Todo lo que va después de # nunca llega al servidor: el navegador lo usa localmente, para saltar a una sección de la página o para el enrutamiento de una aplicación de una sola página. Tiene sus propias reglas de codificación, pero un dato sensible en el fragmento no queda en los logs del servidor, y tampoco puede leerse desde él.
Errores clásicos
Concatenar en lugar de codificar
const url = "https://api.ejemplo.com/buscar?q=" + "pan & vino";
// https://api.ejemplo.com/buscar?q=pan & vino
// El servidor recibe q = "pan " y un parámetro vacío llamado " vino".
La solución robusta es no armar la query a mano:
const url = new URL("https://api.ejemplo.com/buscar");
url.searchParams.set("q", "pan & vino");
// https://api.ejemplo.com/buscar?q=pan+%26+vino
Usar la función equivocada
encodeURI("pan & vino") devuelve pan%20&%20vino: el espacio queda codificado, pero el & no, porque encodeURI asume que recibe una URL completa y respeta sus delimitadores. Para un valor siempre corresponde encodeURIComponent (o URLSearchParams).
Codificar dos veces
Si un valor ya codificado pasa por otro codificador, cada % se convierte en %25: a b → a%20b → a%2520b. Suele ocurrir cuando el código codifica un parámetro y después lo entrega a una librería HTTP que también lo codifica. La pista es ver %25 seguido de dos dígitos hexadecimales en una URL.
Decodificar dos veces
El error inverso tiene consecuencias de seguridad. Si un servidor valida una ruta y después la decodifica otra vez, una entrada como %252e%252e%252f pasa la validación (no contiene ../) y, tras la segunda decodificación, se convierte en ../. Varias vulnerabilidades de recorrido de directorios (path traversal) se basaron en esto. La regla es decodificar una sola vez, en un punto bien definido, y validar después.
La misma operación en distintos lenguajes
Cada lenguaje tiene una función para el formato de formularios (espacio como +) y otra para el estilo del RFC 3986 (espacio como %20), y confundirlas es una fuente habitual de errores:
| Lenguaje | Espacio como %20 (RFC 3986) | Espacio como + (formularios) |
|---|---|---|
| JavaScript | encodeURIComponent(s) | new URLSearchParams({ q: s }) |
| Python | urllib.parse.quote(s, safe="") | urllib.parse.quote_plus(s), urlencode(dict) |
| PHP | rawurlencode($s) | urlencode($s) |
| Go | url.PathEscape(s) | url.QueryEscape(s) |
| Java | URI con sus constructores de varios argumentos | URLEncoder.encode(s, UTF_8) |
| curl | — | --data-urlencode "q=pan & vino" |
Dos trampas puntuales: quote() de Python no codifica / por defecto (su parámetro safe vale "/"), así que para un valor hay que pasar safe="". Y URLEncoder.encode() de Java produce el formato de formularios, con +, aunque su nombre sugiere lo contrario; usarlo para segmentos de ruta es un error común.
Codificación de URL y Base64
A veces hay que meter datos binarios en una URL (un token, una firma). Codificarlos en Base64 y después aplicar codificación porcentual funciona, pero +, / y = se vuelven %2B, %2F y %3D y la URL crece. Por eso existe la variante base64url, que usa - y _ y omite el relleno: el resultado ya es seguro para una URL. Se explica en Base64 explicado: cómo funciona, el relleno y sus variantes.
Resumen
La codificación porcentual convierte cada carácter en sus bytes UTF-8 escritos como %XX. Lo que hay que codificar depende de la parte de la URL: dentro de un valor, todo delimitador es dato y se codifica. Para valores se usa encodeURIComponent o URLSearchParams, nunca la concatenación ni encodeURI. Hay que codificar una vez, decodificar una vez, y recordar que + significa espacio solo en la query string de los formularios.