La mayoría de los suites de tests de autenticación API verifican una sola cosa: que las credenciales válidas obtienen acceso. Los tres estados que realmente fallan en producción son las solicitudes sin autenticación que devuelven 401, las solicitudes con permisos insuficientes que devuelven 403, y los tokens cacheados que expiran durante la ejecución generando fallos confusos en lugar de errores de autenticación claros. Este artículo cubre cómo testear los tres escenarios en API keys, tokens Bearer, JWTs, autenticación Basic y OAuth2, más un fixture reutilizable de Playwright que maneja la renovación automática de tokens.
Las tres preguntas que todo test de autenticación debe responder
Antes de escribir una sola aserción, cada test de API que involucra autenticación necesita responder tres preguntas.
¿Quién soy?
La identidad se establece con una credencial: una API key, un par de usuario/contraseña, un client ID y un secret. El test necesita una forma de proveer esa credencial sin escribirla directamente en el código.
¿Tengo permiso?
La autenticación (probar quién eres) y la autorización (qué puedes hacer) son sistemas distintos. Un token válido para un usuario regular no debería dar acceso a un endpoint exclusivo de administradores. Los tests que solo verifican que los usuarios autenticados pueden acceder a recursos saltean la mitad que realmente falla en producción.
¿Mi credencial sigue siendo válida?
Los tokens expiran. Las API keys se rotan. Los tests que cachean un token al inicio y corren durante horas contra un token de corta duración fallan de maneras confusas: no "error de auth" sino "solicitud fallida" o un 401 en el medio de un suite que arrancó bien.
Cada sección de este artículo responde una o más de estas preguntas. Tenlas presentes cuando diseñes tu suite de tests.
Autenticación con API key
Las API keys son la forma más simple de autenticación: una cadena secreta estática que el cliente envía con cada solicitud. Existen dos estilos de entrega.
Key en un header es más común y más seguro. El nombre del header varía según la API:X-API-Key, Authorization, Api-Key se usan en la práctica.
curl -H "X-API-Key: sk_live_abc123" https://api.example.com/v1/dataimport { test, expect } from '@playwright/test';
test('GET /data con API key en header', async ({ request }) => {
const response = await request.get('https://api.example.com/v1/data', {
headers: {
'X-API-Key': process.env.API_KEY ?? ''
}
});
expect(response.status()).toBe(200);
});curl "https://api.example.com/v1/data?api_key=sk_live_abc123"test('GET /data con API key en query param', async ({ request }) => {
const response = await request.get('https://api.example.com/v1/data', {
params: {
api_key: process.env.API_KEY ?? ''
}
});
expect(response.status()).toBe(200);
});Cuando la misma API key aplica a todos los tests de un suite, configúrala una sola vez en playwright.config.ts en lugar de repetirla en cada test:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'X-API-Key': process.env.API_KEY ?? ''
}
}
});Cada solicitud que el fixture request haga incluirá ese header automáticamente. Eliminas el ruido por test sin perder la capacidad de sobreescribir el header para tests específicos que necesiten otras credenciales.
.env y secrets del repositorio en CI. Una key comprometida en el control de versiones debe tratarse como comprometida de inmediato.Tokens Bearer y JWTs
La autenticación con token Bearer es un proceso en dos pasos: obtener el token y luego usarlo. Los JSON Web Tokens (JWTs) son el formato de token más común: estructuras JSON codificadas en base64 que contienen claims sobre la identidad y los permisos del usuario, más un timestamp de expiración.
Obtener un token se ve como un POST de login estándar:
import { test, expect } from '@playwright/test';
test('obtener un token Bearer y llamar a un endpoint protegido', async ({ request }) => {
// Paso 1: autenticarse y recibir un token
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
expect(loginRes.status()).toBe(200);
const { access_token } = await loginRes.json();
// Paso 2: usarlo en cada solicitud protegida
const profileRes = await request.get('/api/user/profile', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
expect(profileRes.status()).toBe(200);
const profile = await profileRes.json();
expect(profile).toHaveProperty('id');
expect(profile).toHaveProperty('email');
});Para un suite que ejecuta muchos tests bajo el mismo usuario, mueve el login al beforeAll. El token se obtiene una sola vez y se comparte:
import { test, expect } from '@playwright/test';
let accessToken: string;
test.beforeAll(async ({ request }) => {
const res = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
const body = await res.json();
accessToken = body.access_token;
});
test('listar órdenes como usuario autenticado', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});
test('obtener detalles de una orden', async ({ request }) => {
const response = await request.get('/api/orders/123', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()). Esto es útil cuando querés verificar que el token contiene los roles o el tiempo de expiración esperados sin llamar a otro endpoint de la API.Autenticación Basic
La autenticación HTTP Basic codifica un usuario y contraseña como base64(usuario:contraseña) y los envía en el header Authorization. Es antigua y aparece principalmente en herramientas internas, APIs legacy y entornos de test.
Playwright maneja el encoding por ti a través de la opción httpCredentials:
test('Basic auth via httpCredentials', async ({ playwright }) => {
const context = await playwright.request.newContext({
baseURL: 'https://api.example.com',
httpCredentials: {
username: process.env.BASIC_USER ?? '',
password: process.env.BASIC_PASS ?? ''
}
});
const response = await context.get('/protected/resource');
expect(response.status()).toBe(200);
await context.dispose();
});También puedes hacerlo manualmente configurando el header directamente:
test('Basic auth via header Authorization', async ({ request }) => {
const credentials = Buffer.from(
`${process.env.BASIC_USER}:${process.env.BASIC_PASS}`
).toString('base64');
const response = await request.get('/protected/resource', {
headers: {
Authorization: `Basic ${credentials}`
}
});
expect(response.status()).toBe(200);
});El enfoque con httpCredentials es más limpio para todo un contexto. El enfoque con header manual es útil cuando necesitás testear casos extremos como credenciales malformadas, o cuando distintas solicitudes dentro del mismo test necesitan credenciales diferentes.
OAuth2 client credentials para tests de servicio a servicio
OAuth2 client credentials es la variante máquina a máquina de OAuth2. No hay login de usuario. Un servicio se autentica con su client ID y secret, recibe un token de acceso y lo usa para llamar a la API de otro servicio. Es común en arquitecturas de microservicios e integraciones con terceros.
El flujo:
curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=myapp&client_secret=secret123&scope=read:orders"En Playwright:
import { test, expect } from '@playwright/test';
async function obtenerTokenClientCredentials(request: any): Promise<string> {
const tokenRes = await request.post('https://auth.example.com/oauth/token', {
form: {
grant_type: 'client_credentials',
client_id: process.env.OAUTH_CLIENT_ID ?? '',
client_secret: process.env.OAUTH_CLIENT_SECRET ?? '',
scope: 'read:orders write:orders'
}
});
expect(tokenRes.status()).toBe(200);
const { access_token } = await tokenRes.json();
return access_token;
}
test('llamar a la API de órdenes con token OAuth2 client credentials', async ({ request }) => {
const token = await obtenerTokenClientCredentials(request);
const response = await request.get('https://api.example.com/v1/orders', {
headers: {
Authorization: `Bearer ${token}`
}
});
expect(response.status()).toBe(200);
});Los tokens de client credentials también expiran. Para un suite que ejecuta muchos tests, aplica la misma estrategia de renovación usada para tokens Bearer: obtén el token una sola vez en beforeAll, controla su expiración y renuévalo antes de usarlo.
El campo scope importa para los tests de autorización. Solicitar read:orders debería permitir GET pero no POST. Solicitar un scope que no te fue otorgado debería devolver 403, no 401. Vale la pena testear esto de forma explícita.
Testear estados de fallo de auth: 401 vs 403
La diferencia entre 401 y 403 no es cosmética. Un 401 significa que la solicitud no tenía credencial válida: el servidor no sabe quién eres. Un 403 significa que el servidor sabe quién sos pero no va a permitirte hacer lo que pediste. Devolver el código incorrecto es un bug, y uno que vale la pena testear.
import { test, expect } from '@playwright/test';
test('401 cuando no se envían credenciales', async ({ request }) => {
// Sin header Authorization — el servidor debe rechazar como no autenticado
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
test('403 cuando está autenticado pero sin permiso suficiente', async ({ request }) => {
// Login como usuario regular (no admin)
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.REGULAR_USER_EMAIL,
password: process.env.REGULAR_USER_PASSWORD
}
});
const { access_token } = await loginRes.json();
// Intentar acceder a un endpoint exclusivo de admin
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(response.status()).toBe(403);
});
test('401 cuando el token está expirado', async ({ request }) => {
// Token conocido como expirado (hardcoded para este test específico)
const expiredToken = process.env.EXPIRED_JWT_TOKEN ?? '';
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${expiredToken}` }
});
expect(response.status()).toBe(401);
});
test('401 cuando el token está malformado', async ({ request }) => {
const response = await request.get('/api/user/profile', {
headers: { Authorization: 'Bearer not.a.valid.jwt' }
});
expect(response.status()).toBe(401);
});Testear tokens expirados es complicado porque no siempre podés controlar el tiempo. Los enfoques prácticos son: mantener un token expirado hardcoded en una variable de entorno del entorno de test (nunca necesita funcionar, solo estar expirado), ejecutar los tests contra un servidor con clock skew configurable, o llamar a un endpoint que permita expirar un token explícitamente.
También vale testear: ¿un token de un entorno funciona en otro? ¿Un token emitido para el usuario A permite acceder a los recursos del usuario B? La segunda pregunta corresponde a la categoría BOLA (Broken Object Level Authorization) que cubre la sección siguiente.
Fixture de auth reutilizable con renovación automática de tokens
Repetir el flujo de login en cada test genera ruido. Peor aún, un token de beforeAll cacheado durante todo un suite largo expirará silenciosamente en medio de la ejecución. La solución es un fixture que maneje las dos cosas: compartir el token entre tests y controlar cuándo necesita renovarse.
// fixtures/auth.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
interface AuthContext {
getToken: () => Promise<string>;
asUser: (role: 'admin' | 'user' | 'readonly') => Promise<string>;
}
interface TokenCache {
token: string;
expiresAt: number; // Timestamp Unix en ms
}
const tokenCache = new Map<string, TokenCache>();
async function fetchToken(
request: APIRequestContext,
email: string,
password: string
): Promise<string> {
const cacheKey = email;
const cached = tokenCache.get(cacheKey);
// Devolver el token cacheado si le quedan más de 30 segundos
if (cached && cached.expiresAt - Date.now() > 30_000) {
return cached.token;
}
const res = await request.post('/api/auth/login', {
data: { email, password }
});
const body = await res.json();
const { access_token, expires_in } = body;
tokenCache.set(cacheKey, {
token: access_token,
expiresAt: Date.now() + expires_in * 1000
});
return access_token;
}
type AuthFixtures = { auth: AuthContext };
export const test = base.extend<AuthFixtures>({
auth: async ({ request }, use) => {
const authContext: AuthContext = {
getToken: () =>
fetchToken(
request,
process.env.TEST_USER_EMAIL ?? '',
process.env.TEST_USER_PASSWORD ?? ''
),
asUser: (role) => {
const credentials: Record<string, { email: string; password: string }> = {
admin: {
email: process.env.ADMIN_EMAIL ?? '',
password: process.env.ADMIN_PASSWORD ?? ''
},
user: {
email: process.env.TEST_USER_EMAIL ?? '',
password: process.env.TEST_USER_PASSWORD ?? ''
},
readonly: {
email: process.env.READONLY_EMAIL ?? '',
password: process.env.READONLY_PASSWORD ?? ''
}
};
const creds = credentials[role];
return fetchToken(request, creds.email, creds.password);
}
};
await use(authContext);
}
});
export { expect } from '@playwright/test';Los tests que usan este fixture son claros y explícitos sobre el rol con el que operan:
import { test, expect } from '../fixtures/auth.fixture';
test('el admin puede listar todos los usuarios', async ({ request, auth }) => {
const token = await auth.asUser('admin');
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` }
});
expect(response.status()).toBe(200);
});
test('el usuario readonly no puede eliminar un registro', async ({ request, auth }) => {
const token = await auth.asUser('readonly');
const response = await request.delete('/api/records/123', {
headers: { Authorization: `Bearer ${token}` }
});
expect(response.status()).toBe(403);
});El caché tipo Map es a nivel de módulo, por lo que persiste entre tests en el mismo proceso worker. El buffer de 30 segundos antes de la expiración garantiza que el token se renueve antes de que realmente se agote, no después de que genere un fallo.
Fundamentos de seguridad: qué bugs de auth buscar
Los tests de autenticación verifican que la mecánica funciona. Los tests de seguridad verifican que esa mecánica no puede eludirse. Son dos cosas distintas, y en la segunda es donde viven los bugs interesantes.
Broken Object Level Authorization (BOLA) es la vulnerabilidad de seguridad de APIs más común. El usuario A inicia sesión y recupera su perfil en/api/users/42. La pregunta a testear: ¿puede el usuario A también recuperar /api/users/43? El ID es predecible, y muchas APIs se olvidan de verificar que el usuario solicitante sea el propietario del recurso solicitado.
test('BOLA: el usuario A no puede leer el perfil del usuario B', async ({ request }) => {
// Autenticarse como usuario A
const loginA = await request.post('/api/auth/login', {
data: { email: process.env.USER_A_EMAIL, password: process.env.USER_A_PASSWORD }
});
const { access_token: tokenA } = await loginA.json();
// Obtener el perfil propio del usuario A para encontrar su ID
const profileA = await request.get('/api/user/me', {
headers: { Authorization: `Bearer ${tokenA}` }
});
const { id: idA } = await profileA.json();
// ID del usuario B (de otra cuenta de test bajo tu control)
const idB = process.env.USER_B_ID ?? '';
// Intentar leer el perfil del usuario B con el token del usuario A
const intentoDeAcceso = await request.get(`/api/users/${idB}`, {
headers: { Authorization: `Bearer ${tokenA}` }
});
// Debe devolver 403, no 200
expect(intentoDeAcceso.status()).toBe(403);
});Otros patrones a verificar:
Auth faltante en endpoints no documentados
Las partes antiguas de una API, los endpoints internos o las rutas recién agregadas a veces no tienen middleware de autenticación. Hacer solicitudes sistemáticas a endpoints sin credenciales y verificar que ninguno devuelva 200 es un sweep test que vale la pena.
Escalada de privilegios
Si tu API tiene un campo de rol en el body de la solicitud (por ejemplo, { "role": "admin" }), un usuario regular que envíe ese campo debería ser ignorado o rechazado, no promovido.
Reutilización del token después del logout
Después de llamar a /api/auth/logout, el token de acceso debería invalidarse del lado del servidor. Usarlo de nuevo debería devolver 401.
test('el token se invalida después del logout', async ({ request }) => {
const loginRes = await request.post('/api/auth/login', {
data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD }
});
const { access_token } = await loginRes.json();
// Confirmar que el token funciona
const antesDelLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(antesDelLogout.status()).toBe(200);
// Cerrar sesión
await request.post('/api/auth/logout', {
headers: { Authorization: `Bearer ${access_token}` }
});
// El token ya no debería ser aceptado
const despuesDelLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(despuesDelLogout.status()).toBe(401);
});Estos tests no requieren un escáner de seguridad. Son solo llamadas a la API con inputs deliberadamente incorrectos o credenciales que no coinciden. La inversión es baja; el valor está en detectar vulnerabilidades antes de un pentest o, peor, de un incidente real.
FAQ
¿Debería usar un solo conjunto de credenciales de test para todo el suite, o credenciales únicas por test?Un conjunto por rol generalmente alcanza. El riesgo de que las credenciales compartidas generen interferencia entre tests es bajo siempre que los tests limpien después de sí mismos. La preocupación mayor es la concurrencia: si los workers paralelos inician sesión con el mismo usuario y el servidor controla las sesiones estrictamente, puedes obtener fallos de auth inesperados. Usar credenciales separadas por worker paralelo evita esto.
¿Cuál es la forma correcta de almacenar múltiples credenciales de test para distintos roles?Usa variables de entorno con prefijos: ADMIN_EMAIL, ADMIN_PASSWORD, READONLY_EMAIL, y así. Mapéalas a nombres de roles en un fixture como se muestra arriba. Nunca almacenes contraseñas en archivos de fixtures o helpers de tests, solo en variables de entorno o en un gestor de secrets.
Si el token de acceso expira durante una ejecución de tests, necesitás intercambiar el refresh token por uno nuevo. El enfoque de caché de tokens del fixture maneja esto: cuando el token cacheado está a menos de 30 segundos de expirar, se vuelve a autenticar. Para flujos OAuth2 con refresh tokens explícitos, almacená tanto el token de acceso como el refresh token en el caché, e intentá un intercambio grant_type=refresh_token antes de recurrir a un re-login completo.
No lo testeás, al menos no a nivel de API. El flujo de authorization code es un flujo de navegador orientado al usuario. Testealo con el fixture page de Playwright y un navegador. Para los tests de API, quedate con flujos que no requieran interacción del usuario: client credentials para servicio a servicio y password grant (si el servidor lo admite) para suplantar usuarios en tests.
Es un problema de diseño del servidor, pero igual tienes que manejarlo. Verifica tanto el código de estado como el body en tu aserción:
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${token}` }
});
// Algunas APIs devuelven 200 con body de error en lugar de 401
const body = await response.json();
expect(body.error).toBeUndefined();
expect(response.status()).toBe(200);Sí, pero con cuidado respecto al estado compartido. Si los tests escriben en los datos del mismo usuario, la ejecución paralela genera race conditions. Ejecuta las operaciones de escritura como usuarios distintos o serialízalas. Los tests de auth de solo lectura se paralelizan sin problemas porque no modifican estado.
→ See also: Pruebas de API con el APIRequestContext de Playwright (Sin Postman) | Manejo de Autenticación en Playwright con storageState (Sin Iniciar Sesión en Cada Test) | Testing de API Avanzado con Playwright: Patrones para Proyectos Reales