Most API auth test suites verify one thing: valid credentials get access. The three states that actually break in production are unauthenticated requests returning 401, valid-but-insufficient-permission requests returning 403, and cached tokens expiring mid-run in ways that produce confusing failures rather than clear auth errors. This article covers testing all three across API keys, Bearer tokens, JWTs, Basic auth, and OAuth2, plus a reusable Playwright fixture that handles token refresh automatically.
The three questions every auth test must answer
Before you write a single assertion, every API test involving authentication needs to answer three questions.
Who am I? Identity is established by a credential: an API key, a username/password pair, a client ID and secret. The test needs a way to supply that credential without hardcoding it. Do I have permission? Authentication (proving who you are) and authorization (what you're allowed to do) are different systems. A valid token for a regular user should not grant access to an admin-only endpoint. Tests that only verify authenticated users can access resources skip the half that actually breaks in production. Is my credential still valid? Tokens expire. API keys get rotated. Tests that cache a token at startup and run for hours against a short-lived token will fail in misleading ways: not "auth error" but "request failed" or a confusing 401 in the middle of a suite that started fine.Every section in this article maps to one or more of these questions. Keep them in mind as you design your test suite.
API key authentication
API keys are the simplest form of authentication: a static secret string that the client sends with every request. They come in two delivery styles.
Key in a header is more common and more secure. The header name varies by API:X-API-Key, Authorization, Api-Key are all used in the wild.
curl -H "X-API-Key: sk_live_abc123" https://api.example.com/v1/dataimport { test, expect } from '@playwright/test';
test('GET /data with API key in 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 with API key in 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);
});When the same API key applies to every test in a suite, configure it once in playwright.config.ts instead of repeating it per 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 ?? ''
}
}
});Every request the request fixture makes will include that header automatically. You remove the per-test noise without losing the ability to override the header for specific tests that need different credentials.
.env file and repository secrets in CI. A key committed to version control should be treated as compromised immediately.Bearer tokens and JWTs
Bearer token authentication is a two-step process: get a token, then use it. JSON Web Tokens (JWTs) are the most common token format: base64-encoded JSON structures that contain claims about the user's identity and permissions, plus an expiry timestamp.
Getting a token looks like a standard POST login:
import { test, expect } from '@playwright/test';
test('fetch a Bearer token and call a protected endpoint', async ({ request }) => {
// Step 1: authenticate and receive a 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();
// Step 2: use it on every protected request
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');
});For a suite that runs many tests under the same user, move the login into beforeAll. The token is fetched once and shared:
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('list orders as authenticated user', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});
test('get order details', 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()). This is useful when you want to assert that the token contains the expected roles or expiry time without calling another API endpoint.Basic auth
HTTP Basic authentication encodes a username and password as base64(username:password) and sends them in the Authorization header. It's old and found mainly in internal tools, legacy APIs, and test environments.
Playwright handles the encoding for you through the httpCredentials option:
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();
});You can also do it manually by setting the header directly:
test('Basic auth via Authorization header', 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);
});The httpCredentials approach is cleaner for a whole context. The manual header approach is useful when you need to test edge cases like malformed credentials or when different requests in the same test need different credentials.
OAuth2 client credentials for service-to-service tests
OAuth2 client credentials is the machine-to-machine variant of OAuth2. There's no user login. A service authenticates with its client ID and secret, receives an access token, and uses that token to call another service's API. It's common in microservice architectures and third-party integrations.
The flow:
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"In Playwright:
import { test, expect } from '@playwright/test';
async function getClientCredentialsToken(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('call orders API with OAuth2 client credentials token', async ({ request }) => {
const token = await getClientCredentialsToken(request);
const response = await request.get('https://api.example.com/v1/orders', {
headers: {
Authorization: `Bearer ${token}`
}
});
expect(response.status()).toBe(200);
});Client credentials tokens also expire. For a suite that runs many tests, the same refresh strategy used for Bearer tokens applies here: get the token once in beforeAll, track its expiry, and refresh before it's used.
The scope field matters for authorization testing. Requesting read:orders should allow GET but not POST. Requesting a scope you haven't been granted should return 403, not 401. These are worth testing explicitly.
Testing auth failure states: 401 vs 403
The difference between 401 and 403 is not cosmetic. A 401 means the request had no valid credential: the server doesn't know who you are. A 403 means the server knows who you are but won't let you do what you asked. Returning the wrong code is a bug, and one worth testing.
import { test, expect } from '@playwright/test';
test('401 when no credentials are sent', async ({ request }) => {
// No Authorization header — server should reject as unauthenticated
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
test('403 when authenticated but lacking permission', async ({ request }) => {
// Log in as a regular user (not 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();
// Attempt to access an admin-only endpoint
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(response.status()).toBe(403);
});
test('401 when token is expired', async ({ request }) => {
// A known-expired token (hardcoded for this specific test)
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 when token is malformed', async ({ request }) => {
const response = await request.get('/api/user/profile', {
headers: { Authorization: 'Bearer not.a.valid.jwt' }
});
expect(response.status()).toBe(401);
});Testing expired tokens is tricky because you can't always control time. The practical approaches are: keep a hardcoded expired token in a test environment variable (it never needs to work, just to be expired), or run tests against a server with configurable clock skew, or call an endpoint that lets you explicitly expire a token.
Also worth testing: does a token from one environment work in another? Does a token issued for user A allow access to user B's resources? The second question is the BOLA (Broken Object Level Authorization) category covered in the next section.
Reusable auth fixture with automatic token refresh
Repeating the login flow in every test creates noise. Worse, a beforeAll token cached for the duration of a long suite will silently expire mid-run. The solution is a fixture that handles both: share the token across tests, but track when it needs to be refreshed.
// 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; // Unix timestamp in 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);
// Return cached token if it has more than 30 seconds remaining
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';Tests using this fixture are clean and explicit about which role they're operating as:
import { test, expect } from '../fixtures/auth.fixture';
test('admin can list all users', 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('readonly user cannot delete a record', 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);
});The Map cache is module-level, so it persists across tests in the same worker process. The 30-second buffer before expiry means a token is refreshed before it actually runs out, not after it causes a failure.
Security testing basics: what auth bugs to look for
Authentication tests verify that the mechanics work. Security tests verify that the mechanics can't be bypassed. These are two different things, and the second is where the interesting bugs live.
Broken Object Level Authorization (BOLA) is the most common API security vulnerability. User A logs in and retrieves their profile at/api/users/42. The question to test: can user A also retrieve /api/users/43? The ID is guessable, and many APIs forget to check that the requesting user owns the requested resource.
test('BOLA: user A cannot read user B profile', async ({ request }) => {
// Authenticate as user 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();
// Get user A's own profile to find their ID
const profileA = await request.get('/api/user/me', {
headers: { Authorization: `Bearer ${tokenA}` }
});
const { id: idA } = await profileA.json();
// Get user B's ID (from another test account you control)
const idB = process.env.USER_B_ID ?? '';
// Try to read user B's profile using user A's token
const attemptedAccess = await request.get(`/api/users/${idB}`, {
headers: { Authorization: `Bearer ${tokenA}` }
});
// Should return 403, not 200
expect(attemptedAccess.status()).toBe(403);
});Other patterns to check:
Missing auth on undocumented endpoints. Older parts of an API, internal endpoints, or recently added routes sometimes lack auth middleware. Systematically requesting endpoints without credentials and checking that none return 200 is a worthwhile sweep test. Privilege escalation. If your API has a role field in the request body (e.g.,{ "role": "admin" }), a regular user sending that field should be ignored or rejected, not promoted.
Token reuse after logout. After calling /api/auth/logout, the access token should be invalidated server-side. Using it again should return 401.
test('token is invalidated after 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();
// Confirm the token works
const beforeLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(beforeLogout.status()).toBe(200);
// Log out
await request.post('/api/auth/logout', {
headers: { Authorization: `Bearer ${access_token}` }
});
// Token should no longer be accepted
const afterLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(afterLogout.status()).toBe(401);
});These tests don't require a security scanner. They're just API calls with deliberate bad inputs or mismatched credentials. The investment is low; the value is catching vulnerabilities before a penetration test or, worse, a real incident.
FAQ
Should I use one set of test credentials for the whole suite, or unique credentials per test?One set per role is usually enough. The risk of shared credentials causing test interference is low as long as tests clean up after themselves. The bigger concern is concurrency: if parallel workers log in as the same user and the server tracks sessions strictly, you can get unexpected auth failures. Separate credentials per parallel worker avoids this.
What's the right way to store multiple test credentials for different roles?Use prefixed environment variables: ADMIN_EMAIL, ADMIN_PASSWORD, READONLY_EMAIL, and so on. Map them to role names in a fixture as shown above. Never store passwords in fixture files or test helpers, only in environment variables or a secrets manager.
If the access token expires during a test run, you need to exchange the refresh token for a new one. The token cache approach in the fixture section handles this: when the cached token is within 30 seconds of expiry, it re-authenticates. For OAuth2 flows with explicit refresh tokens, store both the access token and the refresh token in the cache, and attempt a grant_type=refresh_token exchange before falling back to a full re-login.
You don't, at least not at the API level. Authorization code flow is a user-facing browser flow. Test it with Playwright's page fixture and a browser. For API tests, stick to flows that don't require user interaction: client credentials for service-to-service and password grant (if the server supports it) for user impersonation in tests.
That's a server-side design problem, but you still have to deal with it. Check both the status code and the body in your assertion:
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${token}` }
});
// Some APIs return 200 with error body instead of 401
const body = await response.json();
expect(body.error).toBeUndefined();
expect(response.status()).toBe(200);Yes, but be careful about shared state. If tests write to the same user's data, parallel execution creates race conditions. Run write operations as different users or serialize them. Read-only auth tests parallelize cleanly because they don't modify state.
→ See also: API Testing with Playwright's APIRequestContext (No Postman Required) | Handling Auth in Playwright with storageState (No Logging In Every Test) | Advanced API Testing with Playwright: Patterns for Real Projects