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.html

Security 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