An API that returns a 200 with another user's data when you swap an ID in the URL has a broken object-level authorization bug, and you can catch it with a Playwright API test during regular development without a penetration tester. The same applies to SQL injection, XSS, account brute-force, and sensitive data exposure in API responses. This article covers the OWASP Top 10 categories most testable during QA, with complete Playwright TypeScript tests for each one that plug directly into an existing CI suite.
Why QA Should Do Security Testing
Security is everyone's responsibility. QA engineers have advantages:
- Access to the source code and test environments — pentesters often don't
- Understanding of the application logic — you know what should and shouldn't be possible
- Automated test infrastructure — security checks can be part of your CI pipeline
- Frequent testing cadence — security regressions are caught early
The OWASP Top 10 (Most Relevant for QA)
The Open Web Application Security Project publishes the most common web vulnerabilities. These are the ones you'll encounter most often.
1. Broken Access Control
Users accessing resources they shouldn't be able to.
Test:test('regular user cannot access admin API', async ({ request }) => {
// Login as regular member
const loginResp = await request.post('/api/auth/login', {
data: { email: 'member@test.com', password: 'MemberPass1' },
});
const { token } = await loginResp.json();
// Try to access admin endpoint
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` },
});
// Should be 403 Forbidden, not 200
expect(response.status()).toBe(403);
});
test('user cannot view another user profile by changing ID', async ({ request }) => {
const user1Token = await getToken('user1@test.com', 'Pass1');
// User 1 tries to access User 2's profile by guessing ID
const response = await request.get('/api/users/999', {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Should be 403 or 404, not 200 with user 2's data
expect([403, 404]).toContain(response.status());
});2. Authentication Issues
test('account lockout after failed attempts', async ({ request }) => {
// Try wrong password 5 times
for (let i = 0; i < 5; i++) {
await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'WrongPassword' },
});
}
// 6th attempt should be blocked
const response = await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'WrongPassword' },
});
expect(response.status()).toBe(429); // Too Many Requests
const body = await response.json();
expect(body.message).toMatch(/locked|too many/i);
});
test('password reset token expires', async ({ request }) => {
// Request reset
await request.post('/api/auth/forgot-password', {
data: { email: 'user@test.com' },
});
// Wait — in tests, use an expired token from the database or mock
const expiredToken = 'expired-reset-token-12345';
const response = await request.post('/api/auth/reset-password', {
data: { token: expiredToken, password: 'NewPass1!' },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toMatch(/expired|invalid/i);
});3. Injection: SQL Injection
test('login form is not SQL injectable', async ({ page }) => {
await page.goto('/login');
// Classic SQL injection payload
await page.fill('[data-testid="email"]', "' OR '1'='1");
await page.fill('[data-testid="password"]', "' OR '1'='1");
await page.click('[data-testid="submit"]');
// Should NOT be logged in
await expect(page).not.toHaveURL('/dashboard');
await expect(page.getByTestId('error-message')).toBeVisible();
});
test('search is not SQL injectable', async ({ request, authToken }) => {
const injections = [
"'; DROP TABLE users; --",
"' UNION SELECT username, password FROM users--",
"1=1",
"' OR 1=1--",
];
for (const payload of injections) {
const response = await request.get(`/api/users?search=${encodeURIComponent(payload)}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
// Should return 200 with empty/safe results, not error or data dump
expect(response.status()).toBe(200);
const body = await response.json();
// Should not return all users (would indicate injection worked)
const count = Array.isArray(body.data) ? body.data.length : body.length;
expect(count).toBeLessThan(100); // Sanity check — not dumping all users
}
});4. Cross-Site Scripting (XSS)
test('user input is not executed as script', async ({ page }) => {
await page.goto('/login');
// Login first
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
// Try to inject script into a user-controlled field
await page.goto('/profile');
const xssPayload = '<script>alert("XSS")</script>';
await page.fill('[data-testid="display-name"]', xssPayload);
await page.click('[data-testid="save"]');
// Navigate away and back to see if script executes
await page.goto('/dashboard');
await page.goto('/profile');
// Check for dialog (would appear if XSS worked)
let dialogAppeared = false;
page.on('dialog', dialog => {
dialogAppeared = true;
dialog.dismiss();
});
await page.waitForTimeout(1000); // Give time for script to execute if present
expect(dialogAppeared).toBe(false);
// The name should appear as text, not be executed
const nameField = page.getByTestId('display-name');
const value = await nameField.inputValue();
expect(value).toBe(xssPayload); // Stored as text, not executed
});5. Sensitive Data Exposure
test('password not returned in API response', async ({ request, authToken }) => {
const response = await request.get('/api/users/1', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
expect(body.password).toBeUndefined();
expect(body.passwordHash).toBeUndefined();
expect(body.passwordSalt).toBeUndefined();
});
test('auth token not logged in response headers', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'ValidPass1' },
});
// Token should be in body, not exposed in headers to everyone
const headers = response.headers();
expect(headers['x-auth-token']).toBeUndefined();
expect(headers['authorization']).toBeUndefined();
// But should be in body
const body = await response.json();
expect(body.token).toBeDefined();
});
test('API uses HTTPS', async ({ request }) => {
const response = await request.get('/api/users');
// Check for HSTS header
const headers = response.headers();
expect(headers['strict-transport-security']).toBeDefined();
});OWASP ZAP: Automated Security Scanning
OWASP ZAP is a free security scanning tool. You can integrate it with Playwright:
// Using ZAP proxy with Playwright
test.use({ proxy: { server: 'http://localhost:8080' } }); // ZAP proxy
test('security scan through ZAP', async ({ page }) => {
await page.goto('/');
await page.goto('/login');
await page.goto('/products');
// ZAP passively scans all requests made by the browser
});Or run ZAP programmatically:
# Docker ZAP scan
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://staging.myapp.com \
-r zap-report.htmlSecurity Headers Check
test('security headers present', async ({ request }) => {
const response = await request.get('/');
const headers = response.headers();
// Content Security Policy
expect(headers['content-security-policy']).toBeDefined();
// Prevent clickjacking
expect(headers['x-frame-options']).toBeDefined();
// Prevent MIME type sniffing
expect(headers['x-content-type-options']).toBe('nosniff');
// XSS protection (legacy, but good to have)
expect(headers['x-xss-protection']).toBeDefined();
// HTTPS only
expect(headers['strict-transport-security']).toBeDefined();
// Don't send referrer info to external sites
expect(headers['referrer-policy']).toBeDefined();
});Testing Authorization Edge Cases
test.describe('IDOR (Insecure Direct Object Reference)', () => {
test('user cannot delete another user\'s post', async ({ request }) => {
// Get tokens for two different users
const user1Token = await getToken('user1@test.com', 'Pass1');
const user2Token = await getToken('user2@test.com', 'Pass2');
// User 2 creates a post
const createResp = await request.post('/api/posts', {
data: { title: 'User 2 Post', content: 'Content' },
headers: { Authorization: `Bearer ${user2Token}` },
});
const post = await createResp.json();
// User 1 tries to delete User 2's post
const deleteResp = await request.delete(`/api/posts/${post.id}`, {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Should be 403 Forbidden
expect(deleteResp.status()).toBe(403);
// Post should still exist
const getResp = await request.get(`/api/posts/${post.id}`);
expect(getResp.status()).toBe(200);
});
});Summary
Security tests that QA should always include:
| Test | What it checks |
|------|----------------|
| Access control | Users can't access resources above their role |
| IDOR | Users can't access other users' resources by ID |
| Account lockout | Brute force protection |
| SQL injection | User input not interpreted as SQL |
| XSS | User input not executed as script |
| Data exposure | Sensitive fields not returned in API |
| Security headers | HTTPS, CSP, XSS protection headers present |
You don't need to find zero-day exploits. Finding and preventing the top 10 OWASP vulnerabilities makes your application dramatically more secure and prevents the most common attacks that actually happen in the real world.
→ See also: Security Testing Basics Every QA Engineer Should Know | Authentication in API Tests: API Keys, Bearer Tokens, OAuth2, JWT | What Is a REST API? A Practical Guide for QA Engineers