Por qué mi colección fallaba en la primera corrida del Runner (y funcionaba en la segunda)
La colección pasaba en la segunda corrida pero no en la primera. El problema: ASP.NET Core Antiforgery vincula el token CSRF a la identidad del usuario.
Nota para el lector
Este post es parte de la serie de API Testing. Los posts anteriores cubren el análisis con DevTools, la construcción de requests en Postman, el manejo dinámico del token CSRF, y la creación de assertions con pm.test.
Si querés ir al grano:
- Solo ver la causa y la solución → saltá a "La causa real" y "La solución"
- Entender el proceso de debugging completo → leé en orden
El problema
Tenía mi colección "Serenity Demo - Auth + Customer API Flow" lista. Tres requests positivos (los que se seleccionan para esta corrida en el Runner):
- GET - Login Page → obtiene el token CSRF
- POST - Login → autentica con credenciales válidas
- POST - Customer List → consulta la grilla de clientes

Cada request individual funcionaba perfecto. 11 assertions pasando. Todo verde.
Pero cuando corría la colección completa con el Runner, algo raro pasaba:
Primera corrida (cookie jar limpio): GET Login Page ✅, POST Login ✅, POST Customer List ❌ 400 Bad Request. 6 passed, 5 failed.

Segunda corrida (sin limpiar el jar): todo verde. 11 passed, 0 failed.

Mismo código. Misma colección. Misma configuración del Runner. La única diferencia: el estado del cookie jar.
La configuración del Runner
Antes de entrar al debugging, esta era mi configuración del Runner:
- Run manually
- Iterations: 1
- Delay: 0 ms
- Persist responses for a session: ✅
- Stop run if an error occurs: ✅
- Keep variable values: ✅
- Save cookies after collection run: ✅

El "Save cookies after collection run" es clave para entender por qué la segunda corrida andaba: el jar guardaba las cookies de la corrida anterior.
La investigación: Console como herramienta de debug
La Console de Postman (abajo a la izquierda) es donde se ve todo lo que pasa entre bastidores. Cada request que sale, cada respuesta que vuelve, cada console.log que ponés en los scripts.
Abrí la Console, limpié las cookies, y corrí el Runner.
Qué me mostraba la Console en la primera corrida
GET https://demo.serenity.is/Account/Login/?ReturnUrl=%2F 200
"GET Login Page - CSRF cookie:" "CfDJ8CI7vciz_vpFhLi6flAXcC..."
"csrfToken guardado en environment"
"No se encontró la cookie CSRF-TOKEN"
POST https://demo.serenity.is/Account/Login 200
"No se encontró CSRF-TOKEN en Customer List"
POST https://demo.serenity.is/Services/Northwind/Customer/List 400

Dos líneas llamaron mi atención:
- "No se encontró la cookie CSRF-TOKEN" → esto venía del pre-request de POST Login
- "No se encontró CSRF-TOKEN en Customer List" → esto venía del pre-request de Customer List
El GET Login Page encontraba y guardaba el CSRF token en el environment. Pero los pre-request scripts de las siguientes requests no lo estaban leyendo.
Qué me mostraba la Console en la segunda corrida
Corrí de nuevo (sin limpiar el jar). Los logs eran casi idénticos:
GET https://demo.serenity.is/Account/Login/?ReturnUrl=%2F 200
"GET Login Page - CSRF cookie:" "CfDJ8CI7vciz_vpFhLi6flAXcC..."
"csrfToken guardado en environment"
"No se encontró la cookie CSRF-TOKEN"
POST https://demo.serenity.is/Account/Login 200
"No se encontró CSRF-TOKEN en Customer List"
POST https://demo.serenity.is/Services/Northwind/Customer/List 200
Los mismos mensajes de "No se encontró". Pero esta vez Customer List devolvió 200.
Eso descartaba que el problema fuera solo de los scripts. Postman manda las cookies del jar automáticamente en los headers del request, independientemente de lo que pm.cookies.get() devuelva en los pre-request scripts. La diferencia no estaba en el código. Estaba en el estado del cookie jar antes de que empezara la corrida.
Hipótesis 1: los scripts leían de fuentes distintas (era parte del problema)
Mirando mis scripts encontré la primera inconsistencia.
GET Login Page (post-response) guardaba el token en el environment:
const csrfCookie = pm.cookies.get('CSRF-TOKEN');
if (csrfCookie) {
pm.environment.set('csrfToken', csrfCookie);
console.log('csrfToken guardado en environment');
}
POST Login (pre-request) leía del cookie jar:
const csrfCookie = pm.cookies.get('CSRF-TOKEN');
if (csrfCookie) {
pm.variables.set('csrfToken', csrfCookie);
}
Customer List (pre-request) también leía del cookie jar:
const csrfCookie = pm.cookies.get('CSRF-TOKEN');
if (csrfCookie) {
pm.environment.set("csrf_token", csrfCookie);
}
El problema era claro: GET Login Page guardaba en environment, pero POST Login y Customer List leían del cookie jar. En la primera corrida el jar está vacío (o al menos pm.cookies.get() no propaga las cookies del GET a tiempo para los siguientes pre-request scripts en el Runner).
Además, Customer List no estaba seteando el header x-csrf-token. Solo leía del jar y guardaba en una variable, pero nunca hacía pm.request.headers.upsert() para mandarlo como header.
El fix parcial
Unifiqué todos los scripts para que lean del environment y seteen el header:
POST Login (pre-request):
const csrf = pm.environment.get("csrfToken");
if (csrf) {
pm.request.headers.upsert({
key: "x-csrf-token",
value: csrf
});
} else {
console.log("No hay csrfToken en environment para Login");
}
Customer List (pre-request):
const csrf = pm.environment.get("csrfToken");
if (csrf) {
pm.request.headers.upsert({
key: "x-csrf-token",
value: csrf
});
} else {
console.log("No hay csrfToken en environment para Customer List");
}
Limpié el jar, corrí de nuevo.
Resultado: Seguía fallando. Customer List → 400.
Pero ahora el x-csrf-token header sí se estaba enviando. Lo podía ver en los Request Headers de la respuesta del Runner. Entonces el problema no era que el token no llegaba al servidor. Era que el token que llegaba no era válido.

Hipótesis 2: el servidor rota el CSRF token después del login
Mi siguiente idea fue que el servidor emitía un CSRF token nuevo en la respuesta de POST Login. Si el token cambiaba después del login, Customer List estaría mandando el token viejo.
Para verificarlo, puse código de debug en el post-response de POST Login que inspeccionaba los headers Set-Cookie:
const allHeaders = pm.response.headers.all();
const setCookies = allHeaders.filter(h => h.key.toLowerCase() === 'set-cookie');
console.log("=== Set-Cookie headers de POST Login ===");
console.log("Cantidad:", setCookies.length);
setCookies.forEach((h, i) => {
console.log(`Set-Cookie [${i}]:`, h.value.substring(0, 100) + "...");
});
La Console mostró:
=== Set-Cookie headers de POST Login ===
Cantidad: 1
Set-Cookie [0]: ".AspNetAuth=CfDJ8CI7vciz_vpFhLi6flAXcCXFe9L5xMvXuWWFGvsRmbvb7DfTcPiWvN4SI8CZJF0JF..."
CSRF-TOKEN NO está en Set-Cookie de Login

Solo un Set-Cookie: la cookie .AspNetAuth (la cookie de autenticación). El servidor no emitía un CSRF token nuevo en la respuesta del login.
Hipótesis descartada.
Hipótesis 3: el token está bien, las cookies están bien... ¿qué más puede ser?
Acá tuve que parar y pensar.
El header x-csrf-token se enviaba. Las cookies .AspNetCore.Antiforgery, CSRF-TOKEN y .AspNetAuth estaban todas en el request. El servidor respondía 400.
Pero en la segunda corrida, con exactamente el mismo código, todo andaba.
La diferencia: en la segunda corrida, el cookie jar ya tenía .AspNetAuth desde antes de que empezara el flujo. Eso significaba que cuando GET Login Page se ejecutaba en la segunda corrida, lo hacía como un request autenticado (porque el jar tenía la cookie de sesión de la corrida anterior).
Y ahí cayó la ficha.
La causa real: ASP.NET Core Antiforgery y la identidad del usuario
ASP.NET Core usa un sistema de protección contra CSRF (Cross-Site Request Forgery) que funciona con dos piezas:
- Una cookie (
.AspNetCore.Antiforgery_*) que el servidor setea - Un token (
CSRF-TOKEN/x-csrf-token) que el cliente manda como header
El servidor valida que las dos piezas correspondan entre sí y que correspondan a la identidad del usuario actual.
El flujo en la primera corrida (jar limpio) era:
- GET Login Page → el servidor genera el CSRF token para un usuario anónimo. Setea la cookie
CSRF-TOKENy.AspNetCore.Antiforgery. Mi script guarda el token en el environment. - POST Login → mando el token del environment como header
x-csrf-token. El servidor valida (anónimo → anónimo, coincide), autentica, emite.AspNetAuth. Ahora soy un usuario autenticado. - POST Customer List → mando el mismo token del environment. Pero este token fue generado para un usuario anónimo. La identidad cambió después del login. El par cookie/token del antiforgery ya no es válido para la sesión actual. El servidor rechaza: 400 Bad Request.
Por qué la segunda corrida funcionaba: el jar ya tenía .AspNetAuth de la corrida anterior. Cuando GET Login Page se ejecutaba, el servidor veía la cookie de autenticación, sabía que era un usuario autenticado, y generaba un CSRF token para esa sesión autenticada. Entonces cuando Customer List mandaba ese token, el par era válido.
En un browser real esto no pasa porque después del login hay una redirección que carga una nueva página. Esa carga genera automáticamente un CSRF token fresco para la sesión autenticada. En Postman, con requests secuenciales, ese paso intermedio no existe.
La solución: GET Refresh Token (Post-Login)
Agregué un request nuevo en la colección, justo después de POST Login:
GET - Refresh Token (Post-Login)
- URL:
{{baseUrl}}/Account/Login/?ReturnUrl=%2F - Método: GET
Con un post-response script:
const csrfCookie = pm.cookies.get("CSRF-TOKEN");
if (csrfCookie) {
pm.environment.set("csrfToken", csrfCookie);
console.log("CSRF token refreshed post-login:", csrfCookie.substring(0, 50) + "...");
}

La colección quedó así:
1. GET - Login Page → obtiene CSRF token (anónimo)
2. POST - Login → autentica, cambia identidad
3. GET - Refresh Token (Post-Login) → obtiene CSRF token NUEVO (autenticado)
4. POST - Login (user correcto, pass mal)
5. POST - Login (user mal, pass correcta)
6. POST - Login (user y pass incorrectos)
7. POST - Login (user y pass vacíos)
8. POST - Customer List → usa el token autenticado
Limpié el jar. Corrí el Runner.
All 11 Passed 11 Failed 0 Skipped 0

La Console confirmó el flujo:
GET Login Page → CSRF cookie capturado, csrfToken guardado en environment
POST Login → 200
GET Refresh Token → 200, CSRF token refreshed post-login (NUEVO valor)
POST Customer List → 200
El detalle del debugging: typo en nombre de variable
Durante la investigación hubo un error que me costó una corrida extra. Tenía:
- GET Login Page guardaba con:
pm.environment.set('csrfToken', ...) - POST Login leía con:
pm.environment.get("csrf_Token")
csrfToken ≠ csrf_Token. JavaScript es case-sensitive. Postman no te avisa que estás leyendo una variable que no existe, solo devuelve undefined.
Lección: unificá el nombre de la variable en todos los scripts. Elegí uno (csrfToken) y usá ese en todos lados.
Scripts finales
GET - Login Page (post-response)
const csrfCookie = pm.cookies.get('CSRF-TOKEN');
console.log('GET Login Page - CSRF cookie:', csrfCookie);
if (csrfCookie) {
pm.environment.set('csrfToken', csrfCookie);
console.log('csrfToken guardado en environment');
} else {
console.log('No se encontró CSRF-TOKEN en GET - Login Page');
}
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response is HTML", function () {
pm.response.to.have.header("Content-Type");
pm.expect(pm.response.headers.get("Content-Type")).to.include("text/html");
});
pm.test("CSRF-TOKEN cookie exists", function () {
const csrfCookie = pm.cookies.get("CSRF-TOKEN");
pm.expect(csrfCookie).to.not.be.undefined;
});
POST - Login (pre-request)
const csrf = pm.environment.get("csrfToken");
if (csrf) {
pm.request.headers.upsert({
key: "x-csrf-token",
value: csrf
});
} else {
console.log("No hay csrfToken en environment para Login");
}
POST - Login (post-response)
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("AspNetAuth cookie exists after login", function () {
const authCookie = pm.cookies.get(".AspNetAuth");
pm.expect(authCookie).to.not.be.undefined;
});
pm.test("CSRF-TOKEN cookie still present", function () {
const csrfCookie = pm.cookies.get("CSRF-TOKEN");
pm.expect(csrfCookie).to.not.be.undefined;
});
GET - Refresh Token Post-Login (post-response)
const csrfCookie = pm.cookies.get("CSRF-TOKEN");
if (csrfCookie) {
pm.environment.set("csrfToken", csrfCookie);
console.log("CSRF token refreshed post-login:", csrfCookie.substring(0, 50) + "...");
}
POST - Customer List (pre-request)
const csrf = pm.environment.get("csrfToken");
if (csrf) {
pm.request.headers.upsert({
key: "x-csrf-token",
value: csrf
});
} else {
console.log("No hay csrfToken en environment para Customer List");
}
Takeaways
Sobre ASP.NET Core Antiforgery: El token CSRF no es solo un valor random que el servidor valida. Está vinculado a la identidad del usuario. Si la identidad cambia (de anónimo a autenticado), el token viejo deja de ser válido. Un browser resuelve esto automáticamente con la redirección post-login. En Postman, hay que hacerlo explícitamente.
Sobre Postman:
pm.cookies.get()en un pre-request script del Runner no siempre refleja las cookies que acaba de setear el request anterior. El cookie jar y los scripts tienen su propio timing.- La Console es la herramienta más valiosa para debugging en Postman. Cada hypothesis que tuve la validé o descarté mirando los logs.
pm.environmentes la forma más confiable de pasar datos entre requests en una colección.
Sobre debugging en general: El problema tardó varias hipótesis en resolverse. La primera (scripts leían de fuentes distintas) era real pero no era la causa raíz. La segunda (el servidor rota el token) parecía lógica pero los datos la descartaron. La tercera (el token está vinculado a la identidad) explicó todos los síntomas: por qué la primera corrida fallaba, por qué la segunda andaba, y por qué agregar un GET post-login lo resolvía.
Estado actual
La colección completa pasa 11/11 en primera corrida con jar limpio. El flujo simula correctamente lo que haría un browser: obtener token → autenticarse → refrescar token → consultar datos.
El cookie jar después de una corrida exitosa tiene 3 cookies: .AspNetAuth, .AspNetCore.Antiforgery_*, y CSRF-TOKEN.

Lo que sigue: operaciones CRUD reales
Crear, verificar, eliminar, confirmar. La colección pasa de solo lectura a modificación de datos.
Hasta ahora toda la colección consulta datos. Las requests son GET y POST de lectura: login, listar clientes. El siguiente paso es testear operaciones que modifican datos: crear un cliente desde Postman, verificar que exista en la grilla, eliminarlo y confirmar que ya no aparece. Eso es CRUD real, no solo consultas.