Codificación y decodificación de URL: guía práctica de percent encoding
Estás revisando los logs del servidor y encuentras esto en una query string: %E4%BD%A0%E5%A5%BD. ¿Datos corruptos? ¿Un bug? Nada de eso: son los caracteres chinos 你好, convertidos a tres bytes UTF-8 cada uno y luego percent-encoded para caber en una URL. Algo parece roto, pero la URL funciona tal cual fue diseñada.
La codificación URL — o percent encoding — hace seguros los caracteres especiales dentro de las URLs. Esta guía cubre cómo funciona a nivel de bytes, cuándo usar encodeURI frente a encodeURIComponent, cómo codificar en cuatro lenguajes y los errores que atrapan hasta a desarrolladores con experiencia.
Pega cualquier URL en nuestro Decodificador y Codificador de URL para ver la codificación y decodificación en tiempo real mientras sigues esta guía.
¿Qué es la codificación URL (Percent Encoding)?
Una URL solo admite un subconjunto reducido de caracteres ASCII. Letras, dígitos y un puñado de símbolos viajan sin problema. Todo lo demás — espacios, ampersands, texto en chino, emojis — necesita convertirse a un formato que las URLs puedan transportar.
El percent encoding reemplaza cada byte no seguro con un signo % seguido de dos dígitos hexadecimales. Un espacio se convierte en %20. Un ampersand se convierte en %26. El nombre viene de ese prefijo %.
Las reglas vienen del RFC 3986, publicado en 2005 y vigente hoy. Reemplazó al RFC 2396 y redefinió qué caracteres son seguros, cuáles reservados y cómo manejar texto no ASCII.
Ejemplos rápidos:
| Entrada | Codificado | Por qué |
|---|---|---|
hello world | hello%20world | Los espacios no están permitidos en URLs |
price=10&tax=2 | price%3D10%26tax%3D2 | = y & tienen significado estructural |
中 | %E4%B8%AD | No ASCII → bytes UTF-8 → percent encoding |
🚀 | %F0%9F%9A%80 | Emoji → 4 bytes UTF-8 → percent encoding |
¿Qué caracteres necesitan codificación?
RFC 3986 divide los caracteres en tres grupos. Conocerlos te ahorra horas de depuración.
Caracteres no reservados (nunca se codifican)
Estos 66 caracteres pasan tal cual en cualquier parte de una URL:
A-Z a-z 0-9 - . _ ~
Solo esos. Letras, dígitos, guion, punto, guion bajo, virgulilla. Nada más pasa sin codificar.
Caracteres reservados (dependen del contexto)
Estos caracteres sirven como delimitadores estructurales en las URLs:
| Carácter | Rol en la estructura de la URL |
|---|---|
: | Separa el esquema de la autoridad (https:) |
/ | Separa segmentos del path |
? | Inicia la query string |
# | Inicia el fragmento |
& | Separa parámetros de query |
= | Separa clave de valor en un parámetro |
@ | Separa userinfo del host |
+ ! $ ' ( ) * , ; [ ] | Otros roles reservados |
La regla es simple: si el carácter reservado cumple su rol estructural, se deja tal cual. Si aparece como dato (dentro del valor de un parámetro, por ejemplo), se codifica.
Todo lo demás (siempre se codifica)
Espacios, corchetes angulares, llaves, pipes, barras invertidas, caracteres no ASCII (chino, árabe, emojis) — todos deben codificarse con percent encoding antes de aparecer en una URL.
Caso especial: el espacio. RFC 3986 lo codifica como %20, pero los formularios HTML usan +. Más sobre este lío después.
Cómo funciona realmente la codificación URL: el pipeline UTF-8
Con caracteres ASCII es directo: tomas el valor del byte en hexadecimal y le antepones %. Un espacio (byte 32, hex 20) se convierte en %20.
Con texto no ASCII, hay tres pasos:
Paso 1 — Carácter a code point Unicode.
El carácter é corresponde al code point U+00E9. El emoji 🚀 corresponde a U+1F680.
Paso 2 — Code point a bytes UTF-8.
UTF-8 usa de 1 a 4 bytes según el rango del code point. é (U+00E9) son dos bytes: 0xC3 0xA9. El emoji del cohete (U+1F680) son cuatro: 0xF0 0x9F 0x9A 0x80.
Paso 3 — Cada byte a %XX.
Cada byte del paso 2 recibe su propio triplete percent-encoded.
El pipeline completo para varios tipos de caracteres:
| Carácter | Code Point | Bytes UTF-8 | Codificado | Factor de expansión |
|---|---|---|---|---|
A | U+0041 | 41 | A (no se codifica) | 1× |
| espacio | U+0020 | 20 | %20 | 3× |
é | U+00E9 | C3 A9 | %C3%A9 | 6× |
中 | U+4E2D | E4 B8 AD | %E4%B8%AD | 9× |
🚀 | U+1F680 | F0 9F 9A 80 | %F0%9F%9A%80 | 12× |
Verifícalo en JavaScript:
const char = '中';
const encoded = encodeURIComponent(char);
console.log(encoded); // '%E4%B8%AD'
// Trace the bytes
const bytes = new TextEncoder().encode(char);
console.log([...bytes].map(b => '%' + b.toString(16).toUpperCase()).join(''));
// '%E4%B8%AD' — matches
Esta expansión importa cuando te acercas a los límites de longitud de URL. Con 20 caracteres chinos ya sumas 180 caracteres de texto percent-encoded.
encodeURI vs encodeURIComponent — Elegir la función correcta
Confundir estas dos funciones es el error de codificación URL más frecuente en JavaScript. Se parecen, pero codifican conjuntos de caracteres distintos.
encodeURI() | encodeURIComponent() | |
|---|---|---|
| Propósito | Codificar una URL completa | Codificar un solo componente (clave o valor de parámetro) |
| Preserva | : / ? # & = @ + $ , | Ninguno de estos |
| Codifica | Espacios, no ASCII, algo de puntuación | Todo excepto A-Z a-z 0-9 - _ . ~ ! ' ( ) * |
| Usar cuando | Tienes una URL completa con espacios o Unicode en el path | Estás construyendo parámetros de query con entrada del usuario |
Un bug que llega a producción con más frecuencia de la que nadie admite:
// ❌ BUG: encodeURI does NOT encode &
const search = 'Tom & Jerry';
const bad = `https://api.example.com/search?q=${encodeURI(search)}`;
// Result: https://api.example.com/search?q=Tom%20&%20Jerry
// The & splits the query string — server sees q=Tom%20 and a separate param %20Jerry
// ✅ FIX: encodeURIComponent encodes & as %26
const good = `https://api.example.com/search?q=${encodeURIComponent(search)}`;
// Result: https://api.example.com/search?q=Tom%20%26%20Jerry
Regla rápida: en caso de duda, usa encodeURIComponent(). Es la opción correcta en el 95% de los casos.
Prueba ambos modos lado a lado en nuestra herramienta URL Encoder →
Codificación URL en cada lenguaje
JavaScript (Browser y Node.js)
// Encode a parameter value
const value = encodeURIComponent('price >= 100 & currency = €');
// 'price%20%3E%3D%20100%20%26%20currency%20%3D%20%E2%82%AC'
// Decode
const original = decodeURIComponent(value);
// 'price >= 100 & currency = €'
// Modern approach: URLSearchParams handles encoding automatically
const params = new URLSearchParams({ q: 'hello world', lang: '中文' });
console.log(params.toString());
// 'q=hello+world&lang=%E4%B8%AD%E6%96%87'
// Note: URLSearchParams uses + for spaces (form encoding)
Python
from urllib.parse import quote, unquote, urlencode
# Encode a path segment
quote('hello world/file name.txt', safe='/')
# 'hello%20world/file%20name.txt'
# Encode query parameters
urlencode({'q': '你好', 'page': '1'})
# 'q=%E4%BD%A0%E5%A5%BD&page=1'
# quote_plus uses + for spaces (form encoding)
from urllib.parse import quote_plus
quote_plus('hello world') # 'hello+world'
quote('hello world') # 'hello%20world'
Go
import "net/url"
// Encode a query value (uses + for spaces)
url.QueryEscape("hello world & more")
// "hello+world+%26+more"
// Encode a path segment (uses %20 for spaces)
url.PathEscape("hello world & more")
// "hello%20world%20&%20more"
// Build a URL safely with url.Values
params := url.Values{}
params.Set("q", "你好世界")
params.Set("page", "1")
fmt.Println(params.Encode())
// "page=1&q=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"
Java
import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
// Encode (uses + for spaces — Java follows form encoding)
String encoded = URLEncoder.encode("hello world & more", StandardCharsets.UTF_8);
// "hello+world+%26+more"
// For RFC 3986 compliance, replace + with %20
String rfc3986 = encoded.replace("+", "%20");
// "hello%20world%20%26%20more"
// Decode
String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
// "hello world & more"
Ojo con Go y Java: ambos usan codificación de formularios por defecto (espacios como +). Para salida RFC 3986, reemplaza + con %20 en el resultado.
Cinco bugs de codificación URL que rompen producción
1. Doble codificación (%2520 en lugar de %20)
Codificas una cadena, la pasas por un framework que la codifica otra vez, y el % en %20 se convierte en %25. El servidor acaba viendo el texto literal %20 en vez de un espacio.
Síntoma: Las URLs contienen %2520, %253D u otros patrones %25xx.
Diagnóstico: Si ves %25 en una URL, sospecha. Significa que un % fue codificado, señal casi segura de doble codificación.
Solución: Decodifica primero, codifica una sola vez. Antes de codificar, verifica que la cadena no venga ya codificada.
// Detect double encoding
function isDoubleEncoded(str) {
return /%25[0-9A-Fa-f]{2}/.test(str);
}
// Safe encode: decode first, then encode
function safeEncode(str) {
try { str = decodeURIComponent(str); } catch (e) { /* not encoded, that's fine */ }
return encodeURIComponent(str);
}
2. + en segmentos del path
Codificas un nombre de archivo con una librería que genera + para los espacios. El archivo my report.pdf queda como my+report.pdf. El servidor trata el + como literal y devuelve un 404.
La regla: + significa espacio solo en query strings (después de ?). En segmentos del path, + es simplemente +. Usa siempre %20 para espacios en paths.
3. URIs de redirección OAuth rotas
La URL de autorización se ve así:
https://auth.provider.com/authorize?redirect_uri=https://myapp.com/callback?code=abc&state=xyz
El servidor OAuth interpreta redirect_uri=https://myapp.com/callback?code=abc y trata state=xyz como parámetro de nivel superior. La autenticación falla.
Solución: Codifica el valor completo del redirect URI:
const redirectUri = 'https://myapp.com/callback?code=abc&state=xyz';
const authUrl = `https://auth.provider.com/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`;
// redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback%3Fcode%3Dabc%26state%3Dxyz
4. Texto no ASCII ilegible en logs
Los logs del servidor muestran %E4%BD%A0%E5%A5%BD en lugar de caracteres chinos legibles. No es un bug — la URL está bien codificada. Tu visor de logs simplemente no decodifica las secuencias percent-encoded.
Solución: Pasa los logs por un decodificador, o pega la URL en un Decodificador de URL para leer el texto original.
5. Fallos en la firma de APIs
OAuth 1.0 y AWS Signature V4 exigen codificación estricta RFC 3986. Pero encodeURIComponent() en JavaScript no codifica !, ', (, ) ni *. Si alguno aparece en tu input de firma, la firma no va a coincidir.
Solución: Post-procesa la salida:
function rfc3986Encode(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, c =>
'%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}
%20 vs + — El dilema de la codificación del espacio
Dos estándares, un carácter, confusión interminable.
| Estándar | El espacio se convierte en | Dónde aplica |
|---|---|---|
| RFC 3986 (sintaxis de URI) | %20 | En toda la URL |
application/x-www-form-urlencoded | + | Query strings de envíos de formularios HTML |
La convención del + viene de los primeros días de la web. Cuando un navegador envía un <form> HTML con method="GET", codifica los espacios como + en la query string. Está así en la spec de HTML y no va a desaparecer.
El problema: + solo significa “espacio” en query strings. En el path, + es un signo de más literal. https://example.com/my+file.pdf sirve un archivo llamado my+file.pdf, no my file.pdf.
Guía práctica:
- Usa
%20cuando construyas URLs manualmente o codifiques segmentos del path. Funciona en todas partes. - Acepta
+al parsear query strings de envíos de formularios — tu framework probablemente ya lo maneja. - No los mezcles. Elige una convención por componente y mantenla.
Codificación URL y seguridad
La codificación URL NO es cifrado
Percent encoding es una transformación reversible y determinista. Sin clave, sin secreto, cero seguridad. Cualquiera decodifica %48%65%6C%6C%6F de vuelta a Hello en milisegundos.
No uses codificación URL para ocultar datos sensibles. Usa HTTPS para cifrar la petición. Las URLs aparecen en logs, historial del navegador y cabeceras Referer — la información sensible va en el cuerpo de la petición, no en la URL.
Ataques de redirección abierta
Los atacantes codifican URLs para evadir validaciones ingenuas. Un parámetro de redirección con %2F%2Fevil.com se decodifica a //evil.com, y el navegador lo interpreta como URL relativa al protocolo que apunta al dominio del atacante.
Defensa: Valida siempre la URL decodificada, no la forma codificada. Usa listas blancas para los dominios de redirección.
Exploits de doble codificación
Un WAF revisa las URLs buscando etiquetas <script>. El atacante envía %253Cscript%253E — el WAF ve texto percent-encoded inofensivo. La aplicación lo decodifica una vez a %3Cscript%3E, una segunda pasada produce <script>, y el filtro queda burlado.
Defensa: Normaliza toda la entrada (decodifica completamente) antes de aplicar controles de seguridad. No confíes en una sola pasada de decodificación.
Si quieres profundizar, mira nuestra guía de Fundamentos de Seguridad Web.
Límites de longitud de URL y cuándo la codificación se vuelve costosa
La spec HTTP no define una longitud máxima de URL. En la práctica, cada capa de la pila impone la suya.
| Capa | Límite |
|---|---|
| Recomendación general | 2,000 caracteres |
| Chrome, Firefox | ~2 MB (pero los servidores rechazan mucho antes) |
| Apache (por defecto) | 8,190 bytes |
| Nginx (por defecto) | 8,192 bytes |
| IIS | 16,384 bytes (query string) |
| CDNs, proxies | Varía — frecuentemente 4,096-8,192 bytes |
El percent encoding alarga las URLs rápido. Un carácter chino pasa de 1 a 9 caracteres (%E4%B8%AD). Un emoji, a 12. Con 200 caracteres chinos en la query string ya suman 1,800 caracteres de percent encoding antes de la URL base.
Cuando llegas al límite: Mueve los datos al cuerpo de la petición con POST. Para búsquedas, un endpoint POST que acepte JSON con los criterios resuelve el problema.
FAQ
¿Qué es la codificación URL y por qué la necesitan los desarrolladores?
La codificación URL (percent encoding) convierte los caracteres no permitidos en URLs a secuencias hexadecimales %XX. Las URLs solo admiten 66 caracteres ASCII no reservados. Espacios, ampersands, texto Unicode y la mayoría de la puntuación se codifican para no romper la estructura de la URL ni causar interpretaciones erróneas en el servidor.
¿Cuál es la diferencia entre encodeURI y encodeURIComponent?
encodeURI() codifica una URL completa preservando caracteres estructurales como ://, /, ? y &. encodeURIComponent() codifica todo excepto A-Z a-z 0-9 - _ . ~ ! ' ( ) *. Usa encodeURIComponent() para valores de parámetros de query. Usa encodeURI() solo cuando tengas una URL completa y quieras corregir espacios o caracteres no ASCII sin romper su estructura.
¿Por qué %20 aparece a veces como + en las URLs?
Ambos representan un espacio, pero vienen de estándares distintos. %20 sigue RFC 3986 y funciona en toda la URL. + viene de la codificación de formularios HTML y solo funciona en query strings. En el path, + es un signo de más literal. %20 siempre es seguro; + es una convención heredada de los formularios HTML.
¿Cómo codifico texto en URL con Python, JavaScript, Go y Java?
JavaScript: encodeURIComponent('hello world') → hello%20world. Python: urllib.parse.quote('hello world') → hello%20world. Go: url.QueryEscape("hello world") → hello+world. Java: URLEncoder.encode("hello world", UTF_8) → hello+world. Go y Java usan por defecto codificación de formularios (espacio como +) — reemplaza + con %20 para salida conforme a RFC 3986.
¿Se puede usar la codificación URL para seguridad o cifrado?
No. La codificación URL es reversible sin clave — cualquiera la decodifica al instante. Cero confidencialidad. Para datos sensibles usa HTTPS (cifrado TLS), no percent encoding. Las URLs aparecen en logs, historial del navegador y cabeceras Referer: los datos sensibles van en el cuerpo de la petición, no en la URL.
¿Qué es la doble codificación y cómo se soluciona?
La doble codificación ocurre cuando una cadena ya codificada se codifica otra vez. El % en %20 se convierte en %25, produciendo %2520. El servidor ve %20 literal en vez de un espacio. Solución: decodifica primero, codifica una sola vez. El patrón %25 seguido de dos hex digits delata el problema.
¿Cuál es la longitud máxima de una URL?
No hay máximo oficial en la spec HTTP, pero 2,000 caracteres es el límite práctico para compatibilidad general. Apache por defecto acepta 8,190 bytes, Nginx 8,192. Los caracteres no ASCII se expanden entre 3x y 12x con percent encoding, así que las URLs internacionalizadas chocan con el límite mucho antes. Para payloads grandes, usa POST con cuerpo de petición.