API Authentication in Playwright: Basic, API Key, Bearer Tokens
Almost every real API is protected, and how your framework handles authentication decides whether the suite is fast and clean, or slow and full of copy-pasted login code. Logging in inside every test wastes time, can hit rate limits and spreads credentials everywhere. In this lesson you’ll handle Basic auth, API keys and Bearer tokens the Playwright way: a setup project that logs in once, fixtures that hand each test a ready, authenticated client per role, and focused tests for refresh and logout.
httpCredentials for Basic auth, extraHTTPHeaders for API keys, and for Bearer tokens log in once in a setup project (a project listed in dependencies), save the tokens, and create custom fixtures that build an APIRequestContext per role with Authorization: Bearer <token> in extraHTTPHeaders. For cookie-based sessions, save and reuse storageState. Keep credentials in environment variables or CI secrets, and still write explicit tests for login failures, expiry, refresh and logout.Three Ways to Handle Auth (and Which to Pick)
| Approach | How | Verdict |
|---|---|---|
| Log in inside every test | beforeEach calls /auth/login | Simple but slow; hits login rate limits; noisy |
| Log in once per worker | Worker-scoped fixture | Good for short-lived tokens |
| Log in once per run (setup project) | Setup project saves tokens; fixtures read them | Fastest; recommended when tokens outlive the run |
The practice API’s access tokens last 15 minutes, comfortably longer than a typical run. For tokens shorter than your run, use a worker-scoped fixture that refreshes when needed.
Basic Auth and API Keys
These two are just headers, so configure them on a context:
// Basic auth: Playwright builds the Authorization header
const api = await request.newContext({
baseURL: 'https://sqatools.in/wp-json/sqa-api/v1/',
httpCredentials: { username: 'sqa_user', password: 'sqa@123', send: 'always' },
});
// API key: a default header on every request
const keyed = await request.newContext({
baseURL: 'https://sqatools.in/wp-json/sqa-api/v1/',
extraHTTPHeaders: { 'X-API-Key': process.env.API_KEY ?? 'sqa-key-12345' },
});
send: 'always' sends the credentials on the first request. Without it, Playwright waits for a 401 with WWW-Authenticate before retrying with credentials, which doubles requests and may not work with every API. For a whole project, put either setting in the config’s use block.
Bearer Tokens: The Setup Project Pattern
1. Credentials from the environment
src/config.ts:
import path from 'node:path';
export const TOKENS_FILE = path.join(__dirname, '..', '.auth', 'tokens.json');
// Practice credentials are public. For real systems read them from environment variables / CI secrets.
export const USERS = {
admin: { username: process.env.ADMIN_USER ?? 'admin', password: process.env.ADMIN_PASS ?? 'admin@123' },
viewer: { username: process.env.VIEWER_USER ?? 'viewer', password: process.env.VIEWER_PASS ?? 'viewer@123' },
} as const;
Add .auth/ to .gitignore: tokens are secrets too.
2. The setup file
tests/auth.setup.ts runs once before the API tests and logs in each role:
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { TOKENS_FILE, USERS } from '../src/config';
// Runs once before all API tests (project dependency), logs in each role and saves the tokens.
setup('log in admin and viewer', async ({ request }) => {
const tokens: Record<string, string> = {};
for (const [role, creds] of Object.entries(USERS)) {
const res = await request.post('auth/login', { data: creds });
await expect(res, `login ${role}`).toBeOK();
tokens[role] = (await res.json()).access_token;
}
fs.mkdirSync(path.dirname(TOKENS_FILE), { recursive: true });
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens));
});
3. Wire it into the config
// playwright.config.ts (projects section)
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ name: 'api', testIgnore: /.*\.setup\.ts/, dependencies: ['setup'] },
],
Running npx playwright test now runs setup first; if login fails, the API tests are skipped with a clear reason instead of hundreds of confusing 401 failures.
4. Role fixtures
The complete src/fixtures.ts adds two pre-authenticated contexts, adminApi and viewerApi:
import { test as base, expect, APIRequestContext } from '@playwright/test';
import fs from 'node:fs';
import { ObjectsApi } from './api/ObjectsApi';
import { UsersApi } from './api/UsersApi';
import { AuthApi } from './api/AuthApi';
import { TOKENS_FILE } from './config';
type ApiFixtures = {
usersApi: UsersApi;
objectsApi: ObjectsApi;
authApi: AuthApi;
adminApi: APIRequestContext; // request context already carrying the admin token
viewerApi: APIRequestContext;
cleanup: (fn: () => Promise<unknown>) => void;
};
const tokens = () => JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf8')) as Record<'admin' | 'viewer', string>;
export const test = base.extend<ApiFixtures>({
usersApi: async ({ request }, use) => { await use(new UsersApi(request)); },
objectsApi: async ({ request }, use) => { await use(new ObjectsApi(request)); },
authApi: async ({ request }, use) => { await use(new AuthApi(request)); },
adminApi: async ({ playwright, baseURL }, use) => {
const ctx = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { Accept: 'application/json', Authorization: `Bearer ${tokens().admin}` },
});
await use(ctx);
await ctx.dispose();
},
viewerApi: async ({ playwright, baseURL }, use) => {
const ctx = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { Accept: 'application/json', Authorization: `Bearer ${tokens().viewer}` },
});
await use(ctx);
await ctx.dispose();
},
// Register cleanup steps during a test; they run after it, even if it failed.
cleanup: async ({}, use) => {
const tasks: Array<() => Promise<unknown>> = [];
await use(fn => tasks.push(fn));
for (const task of tasks.reverse()) await task().catch(() => {});
},
});
export { expect };
Fixtures are lazy: a test that doesn’t ask for adminApi never reads the token file or creates that context.
An AuthApi Service Object
src/api/AuthApi.ts keeps the auth endpoints in one place for tests that exercise auth itself:
import { APIRequestContext, expect } from '@playwright/test';
export interface Tokens {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
}
export class AuthApi {
constructor(private readonly request: APIRequestContext) {}
login(username: string, password: string, expires_in?: number) {
return this.request.post('auth/login', { data: { username, password, expires_in } });
}
async loginOrThrow(username: string, password: string): Promise<Tokens> {
const res = await this.login(username, password);
await expect(res, `login as ${username}`).toBeOK();
return res.json();
}
me(token: string) {
return this.request.get('auth/me', { headers: { Authorization: `Bearer ${token}` } });
}
refresh(refresh_token: string) {
return this.request.post('auth/refresh', { data: { refresh_token } });
}
logout(token: string) {
return this.request.post('auth/logout', { headers: { Authorization: `Bearer ${token}` } });
}
}
toBeOK() is an async matcher, so it must always be awaited, in helpers as well as in tests. A missing await lets the code continue before the check finishes, and a failed login can go unnoticed.
The Auth Tests
tests/auth.spec.ts covers every scheme, roles, refresh and logout:
import { test, expect } from '../src/fixtures';
import { request as pwRequest } from '@playwright/test';
import { ObjectsApi } from '../src/api/ObjectsApi';
const BASE = 'https://sqatools.in/wp-json/sqa-api/v1/';
test.describe('Authentication in Playwright', () => {
test('Basic auth with httpCredentials', async () => {
const api = await pwRequest.newContext({
baseURL: BASE,
httpCredentials: { username: 'sqa_user', password: 'sqa@123', send: 'always' },
});
expect((await api.get('basic/profile')).status()).toBe(200);
await api.dispose();
});
test('API key via extraHTTPHeaders', async () => {
const api = await pwRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { 'X-API-Key': 'sqa-key-12345' } });
const res = await api.get('apikey/profile');
expect((await res.json()).data.plan).toBe('free');
await api.dispose();
});
test('Bearer token from the setup project', async ({ adminApi }) => {
const me = await (await adminApi.get('auth/me')).json();
expect(me.data).toMatchObject({ username: 'admin', role: 'admin', can_write: true });
});
test('role checks with two pre-authenticated contexts', async ({ adminApi, viewerApi }) => {
const adminObjects = new ObjectsApi(adminApi, 'token/objects');
const viewerObjects = new ObjectsApi(viewerApi, 'token/objects');
expect((await viewerObjects.create({ name: 'viewer try', ttl: 30 })).status()).toBe(403);
const created = await adminObjects.createOrThrow({ name: 'admin owned', ttl: 60 });
expect((await viewerObjects.get(created.id)).status()).toBe(200);
expect((await viewerObjects.delete(created.id)).status()).toBe(403);
expect((await adminObjects.delete(created.id)).status()).toBe(204);
});
test('token refresh rotates; logout revokes', async ({ authApi }) => {
const tokens = await authApi.loginOrThrow('admin', 'admin@123');
const refreshed = await authApi.refresh(tokens.refresh_token);
await expect(refreshed).toBeOK();
expect((await authApi.refresh(tokens.refresh_token)).status()).toBe(401); // old refresh token is spent
const fresh = (await refreshed.json()).access_token;
expect((await authApi.logout(fresh)).status()).toBe(204);
expect((await authApi.me(fresh)).status()).toBe(401);
});
test('protected endpoints refuse anonymous calls', async ({ request }) => {
for (const path of ['basic/profile', 'apikey/profile', 'auth/me', 'token/objects']) {
expect((await request.get(path)).status(), path).toBe(401);
}
});
});
Short-Lived Tokens: A Worker-Scoped Fixture
If tokens expire in, say, 5 minutes and the run takes 20, log in once per worker and refresh when close to expiry:
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { adminToken: () => Promise<string> }>({
adminToken: [async ({ playwright }, use) => {
const api = await playwright.request.newContext({ baseURL: process.env.API_BASE_URL });
let token = '';
let expiresAt = 0;
await use(async () => {
if (Date.now() > expiresAt - 30_000) { // refresh 30 s before expiry
const res = await api.post('auth/login', { data: { username: 'admin', password: process.env.ADMIN_PASS } });
const body = await res.json();
token = body.access_token;
expiresAt = Date.now() + body.expires_in * 1000;
}
return token;
});
await api.dispose();
}, { scope: 'worker' }],
});
Tests call await adminToken() and always get a valid token without logging in more than necessary.
Cookie Sessions and storageState
For APIs that use session cookies, log in with a context, then save its cookies:
const ctx = await request.newContext({ baseURL: process.env.APP_URL });
await ctx.post('login', { form: { username: 'qa_user', password: process.env.QA_PASS! } });
await ctx.storageState({ path: '.auth/session.json' }); // cookies + origins
await ctx.dispose();
// later: a context (or a browser page) that starts logged in
const loggedIn = await request.newContext({ baseURL: process.env.APP_URL, storageState: '.auth/session.json' });
The same file works for browser tests via use: { storageState: '.auth/session.json' }, which is how hybrid suites log in through the API and test the UI already signed in.
Security Hygiene for Test Credentials
- Use dedicated test accounts per environment, never real people’s accounts.
- Read secrets from
process.env; in CI use the secret store (GitHub Actions secrets, Jenkins credentials). - Ignore
.auth/and.envin Git; never print tokens in logs or reports. - Rotate test credentials; give test users the minimum roles they need.
- Keep explicit negative auth tests even though most tests use pre-authenticated fixtures.
Interview Questions: Authentication in Playwright API Tests
How do you handle authentication in Playwright API tests?
Use httpCredentials for Basic auth and extraHTTPHeaders for API keys. For Bearer tokens, log in once in a setup project or worker fixture, then provide pre-authenticated APIRequestContexts through custom fixtures with the Authorization header set.
What is a setup project in Playwright?
A project listed in another project’s dependencies that runs first, typically to log in and save tokens or storageState. If it fails, dependent tests are skipped.
How do you test multiple roles?
Create a fixture per role (adminApi, viewerApi) with that role’s token, then use them in the same test to verify allowed and forbidden actions, or drive a permission matrix with data-driven tests.
What is storageState?
A JSON snapshot of cookies and local storage from a context. Saving it after login and loading it into new contexts or browser pages lets tests start already authenticated.
What does send: ‘always’ do in httpCredentials?
It sends the Basic Authorization header with the first request instead of waiting for a 401 challenge, avoiding an extra round trip and issues with APIs that don’t send WWW-Authenticate.
How do you handle tokens that expire during a test run?
Use a worker-scoped fixture that caches the token with its expiry time and logs in or refreshes shortly before it expires.
Why not log in before every test?
It slows the suite, adds load on the auth server, can trigger rate limits and duplicates code. Logging in once and reusing tokens is faster and more reliable.
How do you keep credentials secure in a test framework?
Read them from environment variables or CI secrets, keep token files and .env out of version control, avoid logging secrets, and use least-privilege test accounts.
Why must logout tests use their own tokens?
Logging out revokes the token; if it were the shared token from the setup project, every other test using it would start failing with 401.
What is the difference between test-scoped and worker-scoped fixtures?
Test-scoped fixtures are created for each test; worker-scoped fixtures are created once per worker process and shared by all tests in that worker, which is ideal for expensive setup like logins.