A maioria das suites de teste de autenticação de API verifica uma coisa: credenciais válidas obtêm acesso. Os três estados que realmente quebram em produção: requisições não autenticadas (401), permissões válidas mas insuficientes (403), e tokens em cache que expiram no meio de uma execução. Tokens expirados produzem falhas confusas em vez de erros claros de auth.

As três perguntas que todo teste de auth deve responder

Antes de escrever uma única assertion, todo teste de API envolvendo autenticação precisa responder três perguntas.

Quem sou eu? A identidade é estabelecida por uma credencial: uma API key, um par de usuário/senha, um client ID e secret. O teste precisa de uma forma de fornecer essa credencial sem hardcoding. Tenho permissão? Autenticação (provar quem você é) e autorização (o que você tem permissão de fazer) são sistemas diferentes. Um token válido para um usuário comum não deve conceder acesso a um endpoint de admin. Testes que só verificam que usuários autenticados podem acessar recursos pulam a metade que realmente quebra em produção. Minha credencial ainda é válida? Tokens expiram. API keys são rotacionadas. Testes que fazem cache de um token na inicialização e rodam por horas contra um token de curta duração falharão de formas enganosas: não "erro de auth" mas "requisição falhou" ou um 401 confuso no meio de uma suite que começou bem.

Cada seção deste artigo mapeia para uma ou mais dessas perguntas. Tenha-as em mente ao projetar sua suite de testes.

Autenticação com API key

API keys são a forma mais simples de autenticação: uma string secreta estática que o cliente envia com cada requisição. Elas vêm em dois estilos de entrega.

Key em um header é mais comum e mais seguro. O nome do header varia por API: X-API-Key, Authorization, Api-Key são todos usados na prática.

curl -H "X-API-Key: sk_live_abc123" https://api.exemplo.com/v1/data

import { test, expect } from '@playwright/test';

test('GET /data com API key no header', async ({ request }) => {
  const response = await request.get('https://api.exemplo.com/v1/data', {
    headers: {
      'X-API-Key': process.env.API_KEY ?? ''
    }
  });

  expect(response.status()).toBe(200);
});

Key em um query parameter é menos comum e menos seguro (a chave acaba nos logs do servidor e no histórico do navegador), mas algumas APIs mais antigas ainda usam.

curl "https://api.exemplo.com/v1/data?api_key=sk_live_abc123"

test('GET /data com API key em query param', async ({ request }) => {
  const response = await request.get('https://api.exemplo.com/v1/data', {
    params: {
      api_key: process.env.API_KEY ?? ''
    }
  });

  expect(response.status()).toBe(200);
});

Quando a mesma API key se aplica a todos os testes de uma suite, configure-a uma vez no playwright.config.ts em vez de repeti-la por teste:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'https://api.exemplo.com',
    extraHTTPHeaders: {
      'X-API-Key': process.env.API_KEY ?? ''
    }
  }
});

Cada requisição que o fixture request fizer incluirá esse header automaticamente. Você remove o ruído por teste sem perder a capacidade de sobrescrever o header para testes específicos que precisem de credenciais diferentes.

Nunca hardcode API keys em arquivos de teste. Use variáveis de ambiente localmente via arquivo .env e segredos de repositório no CI. Uma chave commitada no controle de versão deve ser tratada como comprometida imediatamente.

Bearer tokens e JWTs

A autenticação com Bearer token é um processo de dois passos: obter um token, depois usá-lo. JSON Web Tokens (JWTs) são o formato de token mais comum. São estruturas JSON codificadas em base64 com claims sobre identidade, permissões do usuário e timestamp de expiração.

Obter um token parece um POST de login padrão:

import { test, expect } from '@playwright/test';

test('buscar Bearer token e chamar endpoint protegido', async ({ request }) => {
  // Passo 1: autenticar e receber um 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();

  // Passo 2: usar em cada requisição 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 uma suite que roda muitos testes sob o mesmo usuário, mova o login para beforeAll. O token é obtido uma vez e compartilhado:

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 pedidos como usuário autenticado', async ({ request }) => {
  const response = await request.get('/api/orders', {
    headers: { Authorization: `Bearer ${accessToken}` }
  });
  expect(response.status()).toBe(200);
});

test('obter detalhes do pedido', async ({ request }) => {
  const response = await request.get('/api/orders/123', {
    headers: { Authorization: `Bearer ${accessToken}` }
  });
  expect(response.status()).toBe(200);
});

JWTs não são criptografados por padrão. Eles são apenas assinados. O payload é codificado em base64 e legível por qualquer um. Você pode decodificar o token em um teste para ler seus claims: JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()). Isso é útil quando você quer verificar que o token contém os roles esperados ou o tempo de expiração sem chamar outro endpoint de API.

Basic auth

A autenticação HTTP Basic codifica um usuário e senha como base64(usuario:senha) e os envia no header Authorization. É antiga e encontrada principalmente em ferramentas internas, APIs legadas e ambientes de teste.

O Playwright trata a codificação para você através da opção httpCredentials:

test('Basic auth via httpCredentials', async ({ playwright }) => {
  const context = await playwright.request.newContext({
    baseURL: 'https://api.exemplo.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();
});

Você também pode fazer manualmente definindo o header diretamente:

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);
});

A abordagem httpCredentials é mais limpa para um contexto inteiro. A abordagem manual com header é útil quando você precisa testar casos extremos como credenciais malformadas ou quando requisições diferentes no mesmo teste precisam de credenciais diferentes.

OAuth2 client credentials para testes service-to-service

OAuth2 client credentials é a variante machine-to-machine do OAuth2. Não há login de usuário. Um serviço se autentica com seu client ID e secret, recebe um access token, e usa esse token para chamar a API de outro serviço. É comum em arquiteturas de microsserviços e integrações com terceiros.

O fluxo:

curl -X POST https://auth.exemplo.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials&client_id=meuapp&client_secret=secret123&scope=read:orders"

No Playwright:

import { test, expect } from '@playwright/test';

async function getClientCredentialsToken(request: any): Promise<string> {
  const tokenRes = await request.post('https://auth.exemplo.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('chamar API de pedidos com token OAuth2 client credentials', async ({ request }) => {
  const token = await getClientCredentialsToken(request);

  const response = await request.get('https://api.exemplo.com/v1/orders', {
    headers: {
      Authorization: `Bearer ${token}`
    }
  });

  expect(response.status()).toBe(200);
});

Tokens de client credentials também expiram. Para uma suite que roda muitos testes, a mesma estratégia de refresh usada para Bearer tokens se aplica aqui: obtenha o token uma vez em beforeAll, rastreie sua expiração, e atualize antes de usá-lo.

O campo scope importa para testes de autorização. Solicitar read:orders deve permitir GET mas não POST. Solicitar um escopo que não foi concedido deve retornar 403, não 401. Vale testar explicitamente.

Testando estados de falha de auth: 401 vs 403

A diferença entre 401 e 403 não é cosmética. Um 401 significa que a requisição não tinha credencial válida: o servidor não sabe quem você é. Um 403 significa que o servidor sabe quem você é mas não vai deixar você fazer o que pediu. Retornar o código errado é um bug, e vale testar.

import { test, expect } from '@playwright/test';

test('401 quando nenhuma credencial é enviada', async ({ request }) => {
  // Sem header Authorization: o servidor deve rejeitar como não autenticado
  const response = await request.get('/api/admin/users');
  expect(response.status()).toBe(401);
});

test('403 quando autenticado mas sem permissão', async ({ request }) => {
  // Login como usuário comum (não 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();

  // Tentar acessar endpoint de admin
  const response = await request.get('/api/admin/users', {
    headers: { Authorization: `Bearer ${access_token}` }
  });

  expect(response.status()).toBe(403);
});

test('401 quando o token está expirado', async ({ request }) => {
  // Token expirado conhecido (hardcoded para esse teste 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 quando o token está malformado', async ({ request }) => {
  const response = await request.get('/api/user/profile', {
    headers: { Authorization: 'Bearer nao.e.um.jwt.valido' }
  });

  expect(response.status()).toBe(401);
});

Testar tokens expirados é complicado porque você nem sempre pode controlar o tempo. Três abordagens funcionam na prática: manter um token expirado hardcoded em uma variável de ambiente, rodar contra um servidor com clock skew configurável, ou chamar um endpoint que invalida tokens. O token hardcoded nunca precisa funcionar, só precisa estar expirado.

Também vale testar: um token de um ambiente funciona em outro? Um token emitido para o usuário A permite acesso aos recursos do usuário B? A segunda pergunta é a categoria BOLA (Broken Object Level Authorization) coberta na próxima seção.

Fixture de auth reutilizável com refresh automático de token

Repetir o fluxo de login em cada teste cria ruído. Pior ainda, um token beforeAll em cache pela duração de uma suite longa vai expirar silenciosamente no meio da execução. A solução é um fixture que lida com os dois: compartilhe o token entre testes, mas rastreie quando ele precisa ser atualizado.

// 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 em 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);

  // Retorna token em cache se tiver mais de 30 segundos restantes
  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';

Testes usando esse fixture são limpos e explícitos sobre qual role estão usando:

import { test, expect } from '../fixtures/auth.fixture';

test('admin pode listar todos os usuários', 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('usuário readonly não pode deletar um 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);
});

O cache Map é no nível do módulo, então persiste entre testes no mesmo processo worker. O buffer de 30 segundos antes da expiração significa que um token é atualizado antes de realmente acabar, não depois de causar uma falha.

Fundamentos de segurança: que bugs de auth procurar

Testes de autenticação verificam que a mecânica funciona. Testes de segurança verificam que a mecânica não pode ser contornada. São duas coisas diferentes, e a segunda é onde os bugs interessantes vivem.

Broken Object Level Authorization (BOLA) é a vulnerabilidade de segurança de API mais comum. O usuário A faz login e recupera seu perfil em /api/users/42. A pergunta a testar: o usuário A também consegue recuperar /api/users/43? O ID é adivinhável, e muitas APIs esquecem de verificar que o usuário solicitante é dono do recurso solicitado.

test('BOLA: usuário A não pode ler perfil do usuário B', async ({ request }) => {
  // Autenticar como usuário 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();

  // Obter o perfil do usuário A para encontrar seu ID
  const profileA = await request.get('/api/user/me', {
    headers: { Authorization: `Bearer ${tokenA}` }
  });
  const { id: idA } = await profileA.json();

  // Obter o ID do usuário B (de outra conta de teste que você controla)
  const idB = process.env.USER_B_ID ?? '';

  // Tentar ler o perfil do usuário B usando o token do usuário A
  const attemptedAccess = await request.get(`/api/users/${idB}`, {
    headers: { Authorization: `Bearer ${tokenA}` }
  });

  // Deve retornar 403, não 200
  expect(attemptedAccess.status()).toBe(403);
});

Outros padrões para verificar:

Auth faltando em endpoints não documentados. Partes mais antigas de uma API, endpoints internos, ou rotas recentemente adicionadas às vezes não têm middleware de auth. Solicitar endpoints sistematicamente sem credenciais e verificar que nenhum retorna 200 é um teste de varredura válido. Escalação de privilégio. Se sua API tem um campo de role no corpo da requisição (ex: { "role": "admin" }), um usuário comum enviando esse campo deve ser ignorado ou rejeitado, não promovido. Reutilização de token após logout. Após chamar /api/auth/logout, o access token deve ser invalidado no lado do servidor. Usá-lo novamente deve retornar 401.

test('token é invalidado após 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 o token funciona
  const beforeLogout = await request.get('/api/user/profile', {
    headers: { Authorization: `Bearer ${access_token}` }
  });
  expect(beforeLogout.status()).toBe(200);

  // Fazer logout
  await request.post('/api/auth/logout', {
    headers: { Authorization: `Bearer ${access_token}` }
  });

  // Token não deve mais ser aceito
  const afterLogout = await request.get('/api/user/profile', {
    headers: { Authorization: `Bearer ${access_token}` }
  });
  expect(afterLogout.status()).toBe(401);
});

Esses testes não exigem um scanner de segurança. São apenas chamadas de API com entradas deliberadamente inválidas ou credenciais incompatíveis. O investimento é baixo; o valor está em capturar vulnerabilidades antes de um teste de penetração ou, pior, um incidente real.

FAQ

Devo usar um conjunto de credenciais de teste para toda a suite, ou credenciais únicas por teste?

Um conjunto por role geralmente é suficiente. O risco de credenciais compartilhadas causarem interferência entre testes é baixo, desde que os testes limpem após si mesmos. A maior preocupação é a concorrência: se workers paralelos fazem login como o mesmo usuário e o servidor rastreia sessões estritamente, você pode ter falhas inesperadas de auth. Credenciais separadas por worker paralelo evita isso.

Qual é a forma certa de armazenar múltiplas credenciais de teste para diferentes roles?

Use variáveis de ambiente com prefixo: ADMIN_EMAIL, ADMIN_PASSWORD, READONLY_EMAIL, e assim por diante. Mapeie-as para nomes de role em um fixture como mostrado acima. Nunca armazene senhas em arquivos de fixture ou helpers de teste, apenas em variáveis de ambiente ou um gerenciador de segredos.

Minha API usa refresh tokens. Como lidar com eles nos testes?

Se o access token expirar durante uma execução de teste, você precisa trocá-lo pelo refresh token para obter um novo. A abordagem de cache de token no fixture trata isso: quando o token em cache está dentro de 30 segundos da expiração, ele se re-autentica. Para fluxos OAuth2 com refresh tokens explícitos, armazene tanto o access token quanto o refresh token no cache, e tente uma troca grant_type=refresh_token antes de recorrer a um re-login completo.

Como testar um fluxo de authorization code OAuth2? Isso exige um redirecionamento do navegador.

Não testa no nível de API. O fluxo de authorization code é um fluxo de navegador voltado ao usuário. Teste-o com o fixture page do Playwright e um navegador. Para testes de API, use fluxos que não exigem interação do usuário: client credentials para service-to-service e password grant (se o servidor suportar) para impersonação de usuário em testes.

O que devo fazer quando a API retorna 200 com um erro no corpo em vez de um status code adequado?

Esse é um problema de design do lado do servidor, mas você ainda tem que lidar com isso. Verifique tanto o status code quanto o corpo na sua assertion:

const response = await request.get('/api/user/profile', {
  headers: { Authorization: `Bearer ${token}` }
});

// Algumas APIs retornam 200 com corpo de erro em vez de 401
const body = await response.json();
expect(body.error).toBeUndefined();
expect(response.status()).toBe(200);

Posso testar autenticação em paralelo?

Sim, mas cuidado com estado compartilhado. Se testes escrevem nos dados do mesmo usuário, a execução paralela cria race conditions. Rode operações de escrita como usuários diferentes ou serialize-as. Testes de auth somente leitura paralelizam bem porque não modificam estado.

→ Veja também: Testes de API com o APIRequestContext do Playwright (Sem Postman) | Autenticação no Playwright com storageState (Sem Login em Cada Teste) | Testes de API Avançados com Playwright: Padrões para Projetos Reais