Añades --proxy-server=http://user:pass@host:port a los argumentos de inicio, como escribirías un proxy para cURL, y el primer page.goto() se detiene con net::ERR_NO_SUPPORTED_PROXIES. Quitas las credenciales y el error pasa a ser net::ERR_INVALID_AUTH_CREDENTIALS. Puppeteer no tiene una opción de proxy propia. Inicia Chrome, y es Chrome quien decide cómo se usa un proxy, así que la mayoría de las respuestas están en las reglas de Chrome y no en la API de Puppeteer.
Este artículo cubre el flag de inicio y page.authenticate(), un proxy por contexto de navegador, las sesiones fijas y rotativas, SOCKS5, la lista de bypass, la comprobación de la IP de salida, los errores que vas a encontrar y un script completo con reintentos. Todos los ejemplos se ejecutaron el 6 de octubre de 2026 con Puppeteer 25.12.0 y la versión de Chrome 154 que descarga, contra un proxy HTTP local que exige usuario y contraseña y contra un servidor SOCKS5 local. Los mensajes de error citados son los que obtuvimos.
¿Cómo envía Puppeteer el tráfico a través de un proxy?
Puppeteer es una biblioteca de Node.js que controla un navegador real desde el código. No abre conexiones de red por sí misma; eso lo hace el navegador. Por eso un proxy es un ajuste de Chrome, que se indica como flag de línea de comandos al iniciar el navegador o como opción al crear un contexto de navegador. Un contexto de navegador es una sesión aislada dentro de un mismo navegador, con sus propias cookies, caché y almacenamiento, parecida a una ventana de incógnito.
Iniciar sesión en el proxy es un paso aparte, porque Chrome no lee las credenciales de la dirección del proxy. Nuestro proxy de prueba registró esta secuencia para una página HTTPS:
- Chrome envía
CONNECT httpbin.org:443y le pide así al proxy que abra un túnel hacia el sitio. Para una páginahttp://envía directamente la petición. - La petición no lleva credenciales, así que el proxy responde
407 Proxy Authentication Required. RFC 9110 define el 407 como el equivalente del 401 en el lado del proxy. - Puppeteer captura ese desafío y responde con el usuario y la contraseña que recibió
page.authenticate(). - Chrome repite la petición con una cabecera
Proxy-Authorization, el proxy responde200y la conexión cifrada con el sitio se construye dentro del túnel. El proxy ve el nombre del host, no el contenido de la página. - Chrome guarda las credenciales en la caché de autenticación del contexto y las envía de inmediato en las conexiones siguientes.
El paso 5 explica por qué las credenciales acaban perteneciendo al contexto, como muestran las pruebas de más abajo.
¿Cómo se configura un proxy con --proxy-server y page.authenticate()?
npm i puppeteer instala la biblioteca y descarga un Chrome compatible; puppeteer-core es la misma API sin esa descarga. La configuración mínima:
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
args: ["--proxy-server=http://pr.proxynet.io:8000"],
});
const page = await browser.newPage();
await page.authenticate({ username: "user", password: "pass" });
await page.goto("https://httpbin.org/ip");
console.log(await page.$eval("body", (el) => el.innerText)); // la IP de salida que ve el sitio
await browser.close();El http:// del flag describe la conexión con el proxy, no las páginas. Un proxy HTTP también lleva los sitios https:// a través del túnel, y su contenido sigue cifrado entre Chrome y el sitio. La referencia de page.authenticate() añade que Puppeteer activa para ello la interceptación de peticiones (request interception) en segundo plano, lo que puede costar algo de rendimiento; pasar null la desactiva.
En nuestras pruebas fallaron tres cosas, y de cada una sale una regla fija:
- Llama a
page.authenticate()antes del primergoto(). Cuando se llamaba después, la primera navegación fallaba connet::ERR_INVALID_AUTH_CREDENTIALS; solo pasaba la siguiente. - Deja las credenciales fuera del flag. Con
user:pass@en la dirección, Chrome 154 rechazó el valor completo connet::ERR_NO_SUPPORTED_PROXIES. Las guías antiguas describen aquí un407; hoy la petición ni siquiera llega al proxy. - No envíes
Proxy-Authorizationpor tu cuenta. Al fijar esa cabecera conpage.setExtraHTTPHeaders(), Chrome rechazó la navegación connet::ERR_INVALID_ARGUMENT.
Valor de --proxy-server | Resultado en Chrome 154 |
|---|---|
http://pr.proxynet.io:8000 | Funciona; las páginas HTTPS pasan por un túnel CONNECT |
pr.proxynet.io:8000 | Funciona; sin esquema, Chrome supone un proxy HTTP |
http://user:pass@pr.proxynet.io:8000 | net::ERR_NO_SUPPORTED_PROXIES |
socks5://host:port | Solo funciona si el servidor no pide contraseña |
socks5h://host:port | net::ERR_NO_SUPPORTED_PROXIES; Chrome no conoce este esquema |
¿Cómo se usa un proxy distinto en cada contexto de navegador?
Puppeteer 22 cambió el nombre de createIncognitoBrowserContext(), el que todavía usan las guías antiguas, a browser.createBrowserContext(). Sus BrowserContextOptions incluyen dos campos de proxy, proxyServer y proxyBypassList. El navegador puede arrancar sin proxy mientras cada contexto recibe el suyo:
import puppeteer from "puppeteer";
const PROXY = "http://pr.proxynet.io:8000";
// Una sesión fija por contexto: mismo ID de sesión -> misma IP de salida durante un máximo de 600 segundos
const sessions = ["a1b2c3", "d4e5f6"];
const browser = await puppeteer.launch(); // el navegador en sí arranca sin proxy
for (const id of sessions) {
const context = await browser.createBrowserContext({ proxyServer: PROXY });
const page = await context.newPage();
await page.authenticate({ username: `user-session-${id}-ttl-600`, password: "pass" });
for (let i = 0; i < 2; i++) {
await page.goto("https://httpbin.org/ip");
console.log(id, await page.$eval("body", (el) => el.innerText));
}
await context.close(); // las cookies, la caché y el ajuste de proxy se van con el contexto
}
await browser.close();Nuestro proxy de prueba asigna las salidas por ID de sesión, como hace un gateway. Los dos contextos salieron por dos direcciones distintas, y cada uno mantuvo su dirección en la segunda petición. Hay dos resultados más que importan en trabajos reales:
- Las credenciales pertenecen al contexto, no a la página. Una segunda página del mismo contexto llamó a
page.authenticate()con otro usuario, y aun así todas sus peticiones siguieron llevando el primer usuario. Una página que nunca hizo esa llamada pasó con las mismas credenciales guardadas en caché. - Un contexto sin
proxyServerhereda el proxy del flag de inicio, pero conserva sus propias credenciales. Con--proxy-serveren el navegador, dos contextos con sus propios ID de sesión salieron igualmente por dos salidas distintas.
Flag --proxy-server | createBrowserContext({ proxyServer }) | |
|---|---|---|
| Alcance | Todo el navegador | Solo ese contexto |
| Salidas separadas en un navegador | Sí, un usuario por contexto | Sí, incluso hosts de proxy distintos |
| Cambiar de proxy | Reiniciar el navegador | Cerrar el contexto y abrir uno nuevo |
| Indicado para | Un script, una salida | Muchas sesiones independientes en paralelo |
La regla práctica: una sesión, un contexto, un usuario.
¿Sesión rotativa o fija? Cuál le conviene a un navegador
Un navegador no envía una sola petición por página. Una página de producto descarga el documento HTML, scripts, hojas de estilo, imágenes y llamadas a la API en segundo plano, a menudo desde varios hosts y por varias conexiones. En un gateway rotativo cada conexión nueva puede recibir una IP de salida nueva; en nuestra prueba, incluso el favicon de una página salió por una dirección distinta de la de la página. Para páginas sin relación entre sí eso no causa problemas. En un inicio de sesión, un carrito o un formulario de varios pasos, en cambio, el sitio ve a un visitante que salta entre direcciones y puede cerrar la sesión.
El gateway lee el tipo de sesión del nombre de usuario, que el Generador de endpoints del panel escribe por ti. Sus partes se leen con facilidad: -country-de elige el país de salida (la ciudad se elige en el mismo generador), -session-a1b2c3-ttl-600 mantiene una salida para ese ID de sesión durante el número de segundos indicado, entre 1 y 60 minutos, y un usuario sin parte de sesión rota.
Para páginas independientes, como listados de categorías o resultados de búsqueda abiertos en contextos de vida corta, los Proxies rotativos son la opción adecuada.
Para todo lo que guarda estado, usa los Proxies de sesión fija: un contexto, un ID de sesión, mantenido más tiempo del que dura el flujo. Cuando termine el trabajo, cierra el contexto para que las cookies y la IP terminen juntas.
La rotación reparte trabajo independiente entre salidas; no es una forma de saltarse los límites de un sitio. Un 429 Too Many Requests o un 403 Forbidden te pide que vayas más despacio, y una IP nueva no cambia esa respuesta.
¿Funciona Puppeteer con un proxy SOCKS5?
Sí, pero sin usuario ni contraseña. La documentación de proxy de Chromium indica que no se admite ningún método de autenticación para SOCKSv5. Nuestro servidor SOCKS5 local lo vio desde el otro lado: el saludo de Chrome solo ofrecía el método 0x00, «sin autenticación». Un servidor que exigía el método de usuario y contraseña (0x02) tenía que rechazar esa oferta, y la navegación fallaba con net::ERR_SOCKS_CONNECTION_FAILED. page.authenticate() no cambió nada, porque SOCKS5 no tiene un desafío al estilo del 407 al que Puppeteer pueda responder.
La solución es identificarte con tu dirección IP. Añade la IP pública de la máquina donde se ejecuta Chrome a la lista de IP autorizadas del panel (hasta 10 direcciones), toma el puerto SOCKS5 que muestra el Generador de endpoints e inicia el navegador sin credenciales:
import puppeteer from "puppeteer";
// Sin usuario ni contraseña: la IP pública de esta máquina debe estar en la lista de IP autorizadas.
// SOCKS5_PORT: el puerto SOCKS5 que muestra el Generador de endpoints.
const { SOCKS5_PORT } = process.env;
const browser = await puppeteer.launch({
args: [`--proxy-server=socks5://pr.proxynet.io:${SOCKS5_PORT}`],
});
const page = await browser.newPage();
await page.goto("https://httpbin.org/ip");
console.log(await page.$eval("body", (el) => el.innerText));
await browser.close();Chrome envió al servidor SOCKS5 nombres de host, no direcciones IP, así que el DNS se resuelve en el proxy y no hace falta la costumbre de socks5h:// que viene de cURL. Para páginas web, un proxy HTTP suele ser la opción más sencilla, porque funciona con page.authenticate().
¿Cómo se dejan algunas direcciones fuera del proxy?
El flag --proxy-bypass-list, o proxyBypassList como array en un contexto, enumera los hosts a los que Chrome accede directamente. En el flag, las reglas se separan con punto y coma o con comas: --proxy-bypass-list=*.internal.example;192.168.0.0/16. En nuestra prueba, un host de la lista se saltó el proxy por completo y se resolvió en la máquina local.
Chrome tiene además reglas implícitas: localhost, *.localhost, 127.0.0.1/8 y [::1] nunca usan el proxy, aunque la lista esté vacía. Si pruebas contra un servidor local y el registro del proxy no muestra nada, esta es la razón. La regla especial <-loopback> elimina esas reglas implícitas; con ella, nuestra petición a 127.0.0.1 pasó por el proxy.
¿Cómo se comprueba la IP de salida?
Compara, en el mismo navegador, un contexto directo con uno que pasa por el proxy. Si las direcciones son distintas, el tráfico va por el proxy:
import puppeteer from "puppeteer";
async function exitIp(context, credentials) {
const page = await context.newPage();
if (credentials) await page.authenticate(credentials);
await page.goto("https://httpbin.org/ip");
const { origin } = JSON.parse(await page.$eval("body", (el) => el.innerText));
await page.close();
return origin;
}
const browser = await puppeteer.launch();
const direct = await browser.createBrowserContext();
const proxied = await browser.createBrowserContext({ proxyServer: "http://pr.proxynet.io:8000" });
const own = await exitIp(direct);
const viaProxy = await exitIp(proxied, { username: "user", password: "pass" });
console.log({ own, viaProxy, proxyWorks: own !== viaProxy });
await browser.close();Si apuntas a un país, consulta también la IP de salida en un servicio de geolocalización. Cuando la comprobación falla, saca a Chrome de la ecuación: ¿Funciona tu proxy? Cómo probar un proxy reúne las pruebas de línea de comandos que distinguen un problema de red de un problema de código.
¿Qué errores vas a ver y qué significan?
Provocamos cada uno de ellos en el entorno de prueba local:
| Lo que ves | Qué pasó | Qué hacer |
|---|---|---|
net::ERR_PROXY_CONNECTION_FAILED | Chrome no pudo llegar al proxy: host o puerto incorrectos, o el tráfico saliente está bloqueado | Revisa la dirección y el puerto; reintentar no ayuda |
net::ERR_INVALID_AUTH_CREDENTIALS | El proxy pidió credenciales y Chrome no tenía ninguna | Llama a page.authenticate() antes del primer goto() |
goto() se resuelve con estado 407 | La contraseña era incorrecta; Puppeteer lo intentó una vez y se rindió | Revisa response.status() y corrige las credenciales |
net::ERR_TUNNEL_CONNECTION_FAILED | El inicio de sesión funcionó, pero el proxy no pudo abrir el túnel hacia el sitio | Revisa la dirección de destino |
net::ERR_NO_SUPPORTED_PROXIES | Chrome rechazó el valor de --proxy-server | Quita user:pass@, usa http:// o socks5:// |
net::ERR_SOCKS_CONNECTION_FAILED | El servidor SOCKS5 quiere contraseña o tu IP no está autorizada | Autoriza tu IP o usa el puerto HTTP |
| La página carga, pero la IP es la tuya | Una regla de bypass o un host loopback se saltó el proxy | Revisa la lista de bypass y <-loopback> |
La fila de la contraseña incorrecta es la que más tarda en descubrirse: nada lanza una excepción y el script sigue adelante con la página 407 del proxy. En un navegador de escritorio, el error de túnel tiene más causas, desde el análisis HTTPS de un antivirus hasta un proxy del sistema que quedó configurado; las tienes en Solucionar err_tunnel_connection_failed en Chrome y Edge. Mientras desarrollas, dos listeners muestran el error real que hay detrás de un tiempo de espera agotado:
page.on("requestfailed", (req) => console.log("FALLÓ", req.url(), req.failure()?.errorText));
page.on("response", (res) => res.status() >= 400 && console.log(res.status(), res.url()));Ejemplo completo: contextos en paralelo, sesiones fijas y reintentos
El script abre seis páginas generadas con JavaScript con un máximo de tres contextos a la vez. Cada intento recibe un contexto nuevo y una sesión fija nueva, de modo que las cookies y la IP de salida cambian juntas. Las imágenes, los medios y las fuentes se bloquean para ahorrar tráfico; page.setRequestInterception() lo hace sin romper page.authenticate(). Los tiempos de espera agotados y los fallos de túnel se reintentan con backoff exponencial, mientras que los errores de configuración y un 403 o 429 del sitio detienen el trabajo.
import puppeteer from "puppeteer";
const PROXY = "http://pr.proxynet.io:8000";
const USER = "user";
const PASS = "pass";
const URLS = Array.from({ length: 6 }, (_, i) => `https://quotes.toscrape.com/js/page/${i + 1}/`);
const CONCURRENCY = 3; // contextos abiertos al mismo tiempo
const ATTEMPTS = 3;
const BLOCKED = new Set(["image", "media", "font"]);
// Errores de configuración: esperar y reintentar no los arregla
const FATAL = ["ERR_PROXY_CONNECTION_FAILED", "ERR_NO_SUPPORTED_PROXIES", "ERR_INVALID_AUTH_CREDENTIALS"];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function scrape(browser, url) {
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
// Un contexto por intento: sus propias cookies y su propia sesión fija (una IP de salida)
const context = await browser.createBrowserContext({ proxyServer: PROXY });
try {
const page = await context.newPage();
const session = crypto.randomUUID().slice(0, 8);
await page.authenticate({ username: `${USER}-session-${session}-ttl-300`, password: PASS });
await page.setRequestInterception(true);
page.on("request", (req) => (BLOCKED.has(req.resourceType()) ? req.abort() : req.continue()));
const res = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
const status = res ? res.status() : 0;
if (status === 403 || status === 429) {
// El sitio te rechaza o te frena: una IP nueva no es la respuesta
throw Object.assign(new Error(`HTTP ${status}: detén el trabajo y baja la frecuencia de peticiones`), { fatal: true });
}
if (status >= 400) throw new Error(`HTTP ${status}`);
await page.waitForSelector("div.quote span.text", { timeout: 10_000 }); // generado con JavaScript
return await page.$$eval("div.quote span.text", (els) => els.map((el) => el.textContent));
} catch (err) {
err.fatal ||= FATAL.some((code) => err.message.includes(code));
if (err.fatal || attempt === ATTEMPTS) throw err;
await sleep(2 ** attempt * 1000 + Math.random() * 1000); // espera antes del siguiente intento
} finally {
await context.close();
}
}
}
const browser = await puppeteer.launch();
const queue = [...URLS];
const results = new Map();
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const url = queue.shift();
try {
results.set(url, `${(await scrape(browser, url)).length} citas`);
} catch (err) {
results.set(url, `ERROR ${err.message.split("\n")[0]}`);
if (err.fatal) queue.length = 0; // detiene todo el trabajo, no solo esta página
}
}
}),
);
await browser.close();
for (const url of URLS) console.log(url, results.get(url) ?? "omitida");A través del proxy de prueba local, las seis páginas volvieron con diez citas cada una en menos de cinco segundos. Con el puerto del proxy mal escrito a propósito, las tres páginas en curso se detuvieron al instante con net::ERR_PROXY_CONNECTION_FAILED y las otras tres quedaron omitidas; una página de prueba local que respondía 429 terminó el trabajo de la misma forma. Empieza con un valor bajo de CONCURRENCY: cada contexto ocupa memoria, y el sitio de destino también tiene límites.
Casos de uso
- Páginas de catálogo y de precios generadas con JavaScript que un cliente HTTP simple ve vacías.
- Comprobar cómo se ven tu propio sitio, tus precios o tus banners de consentimiento desde otro país, con un contexto por país.
- Capturas de pantalla y PDF de páginas públicas tal como las ve un visitante de un país concreto.
- Supervisar tus propias páginas y flujos de pago desde fuera de tu red.
El mismo marco vale para todos: respeta el robots.txt y las condiciones del sitio, usa una API oficial cuando exista y mantén la frecuencia de peticiones en un nivel que el sitio pueda soportar.
Errores frecuentes
- Escribir
user:pass@en--proxy-server. Chrome rechaza el valor connet::ERR_NO_SUPPORTED_PROXIES. - Esperar un usuario nuevo por página. Chrome conserva las primeras credenciales para todo el contexto; usa un contexto por sesión.
- Intentar una contraseña con SOCKS5. Chrome solo ofrece «sin autenticación»; usa la lista de IP autorizadas o el puerto HTTP.
- Probar contra
localhosty fiarse del resultado. Las direcciones loopback se saltan el proxy salvo que añadas<-loopback>. - Tratar una respuesta
407como si fuera una página. Una contraseña incorrecta no lanza excepción; revisaresponse.status(). - Rotar la IP tras un
429o un403. Ve más despacio; el límite tiene que ver con tu comportamiento, no con tu dirección. - Dejar contextos abiertos. Cada uno ocupa memoria; ciérralos en un bloque
finally.
Guía de decisión
| Necesidad | Recomendación |
|---|---|
| Un script, una salida | Flag --proxy-server más page.authenticate() |
| Muchas sesiones independientes en un navegador | createBrowserContext({ proxyServer }), un ID de sesión por contexto |
| Muchas páginas que no dependen entre sí | Usuario rotativo, contextos de vida corta |
| Inicio de sesión, carrito o formulario de varios pasos | Sesión fija más larga que el flujo, un contexto |
| SOCKS5 es obligatorio | Lista de IP autorizadas, sin credenciales |
| Una herramienta que solo acepta el flag de inicio | Lista de IP autorizadas o un reenviador local como proxy-chain |
| Los hosts internos deben ir directos | --proxy-bypass-list o proxyBypassList |
Preguntas frecuentes
¿Tiene puppeteer.launch() una opción de proxy?
No. El proxy va en args como el flag --proxy-server de Chrome, y el inicio de sesión en page.authenticate(). La API propia de Puppeteer solo tiene campos de proxy en browser.createBrowserContext(): proxyServer y proxyBypassList.
¿Puede cada página tener su propio proxy?
No dentro de un mismo contexto. El proxy y las credenciales en caché pertenecen al contexto, así que en nuestra prueba una segunda página con otras credenciales siguió usando las primeras. Da a cada página que necesite su propia salida su propio contexto; un contexto cuesta mucho menos que un navegador nuevo.
¿Qué es proxy-chain y lo necesitas?
proxy-chain es un paquete de código abierto para Node.js cuya función anonymizeProxy() inicia un proxy local sin contraseña y reenvía el tráfico a un proxy upstream con credenciales, de modo que Chrome recibe una dirección simple http://127.0.0.1:<port>. En nuestra prueba funcionó tanto con un upstream HTTP como con uno SOCKS5 que exigían contraseña. Con page.authenticate() o una lista de IP autorizadas no lo necesitas.
¿Se puede cambiar de proxy sin reiniciar el navegador?
Sí, mediante contextos. Cierra el contexto, abre uno nuevo con otro proxyServer u otro ID de sesión y vuelve a llamar a page.authenticate(). Solo el flag --proxy-server queda fijo durante toda la vida del navegador.
¿Por qué el registro del proxy muestra peticiones que no hiciste?
El tráfico de fondo del propio Chrome, como las comprobaciones de actualizaciones y las peticiones a servicios de Google, va al mismo proxy. En nuestro registro, la mayor parte llegó sin credenciales y recibió un 407, porque page.authenticate() solo responde a los desafíos del tráfico de las páginas. No afecta a tus páginas.
¿Un proxy evita los CAPTCHA en Puppeteer?
No. Un proxy cambia de dónde viene el tráfico, mientras que los CAPTCHA también reaccionan a la frecuencia de peticiones, a las señales del navegador y a las sesiones incoherentes. Las causas y las formas legítimas de reducirlos están en Puppeteer y CAPTCHA: por qué aparece y cómo reducirlo.
En resumen
En Puppeteer el proxy es un ajuste de Chrome: el flag --proxy-server para todo el navegador o proxyServer para un contexto, con el inicio de sesión en page.authenticate() antes de la primera navegación. Las credenciales se guardan en caché por contexto, así que monta cada sesión como un contexto, elige sesión fija o rotativa a través del usuario y usa la lista de IP autorizadas para SOCKS5. Revisa response.status() y mantén una frecuencia de peticiones razonable. Para páginas que deben abrirse desde conexiones domésticas normales, las salidas vienen de un pool de Proxies residenciales.




