Introduction

<img src=x onerror=alert(1)> -- paste this into every text input on your site. If you see an alert box, you have an XSS vulnerability. That is the most basic test, and it catches more bugs than you would expect.

You do not need to be a professional penetration tester to find these things. You built the application. You understand its data flows and business logic better than any external auditor will. A methodical checklist and a couple of free tools catch the majority of real-world vulnerabilities before they become real-world incidents.

This guide is organized by severity. The stuff that will actually get you hacked comes first. Lower-severity issues that still matter come later. Everything here is defensive -- run these against your own apps in development and staging.

Security Testing Methodology

Random testing finds random bugs. A plan finds the bugs that matter.

Map the attack surface first: every form, URL parameter, HTTP header, cookie, file upload, API endpoint, WebSocket connection. Then test each surface for known vulnerability classes. Practical checklist:

Do not try to cover everything in one session. First pass: input validation across all forms and endpoints. Second pass: authentication and authorization. Breaking it up keeps you thorough instead of scattered.

Reconnaissance and Information Gathering

You might be surprised by what your own application reveals. Server headers, error pages, default configurations, exposed endpoints. All of it gives an attacker a head start.

Bash - Inspecting HTTP Headers
# Check response headers from your application
curl -I https://your-app.local
# Example output revealing too much information:# HTTP/2 200# server: Apache/2.4.52 (Ubuntu)# x-powered-by: Express# x-aspnet-version: 4.0.30319# Check for missing security headers
curl -s -D - https://your-app.local -o /dev/null | grep -i -E "strict-transport|content-security|x-frame|x-content-type|referrer-policy"# Scan for common hidden paths and files# Check if sensitive files are accidentally exposedfor path in .env .git/config robots.txt sitemap.xml .DS_Store server-status; do
 status=$(curl -s -o /dev/null -w "%{http_code}" https://your-app.local/$path)
 echo "$path: $status"done

Server: Apache/2.4.52 (Ubuntu) is a free hint about which CVEs apply. X-Powered-By: Express narrows it further. Strip these in production. And if Strict-Transport-Security, Content-Security-Policy, or X-Frame-Options are missing entirely, that is a finding on its own.

Check error states. Hit a nonexistent URL. Do you get a framework-default error page with a stack trace? Submit malformed data to an API endpoint. Database column names in the error response? Internal file paths? Every piece of information in an error message is something an attacker can use. Run nmap against your staging server too. You might find a database port or debug interface exposed to the network that should be firewalled.

Testing for Cross-Site Scripting

Application takes user input, renders it without encoding, attacker injects JavaScript that runs in other users' browsers. Steals session cookies, redirects to phishing pages, modifies the DOM, acts on the victim's behalf. Three flavors: reflected (echoed back immediately), stored (saved to database, fires on every page view), and DOM-based (client-side JavaScript writes untrusted data into the DOM).

Stored XSS is the worst. Reflected is the most common. DOM-based is the hardest to find with automated tools.

Identify every place user input appears in HTML output and throw these at it:

XSS Test Payloads
<!-- Basic XSS test payloads for your own applications --><!-- Try these in search fields, comment forms, profile names, etc. --><!-- Level 1: Simple script injection -->
<script>alert('XSS')</script>
<!-- Level 2: Event handler injection -->
<img src=x onerror=alert('XSS')>
<!-- Level 3: Bypassing basic filters that strip <script> -->
<svg onload=alert('XSS')>
<body onload=alert('XSS')>
<input onfocus=alert('XSS') autofocus>
<!-- Level 4: Attribute injection when input lands inside an attribute -->
" onmouseover="alert('XSS')
' onfocus='alert(document.cookie)' autofocus='
<!-- Level 5: JavaScript URL scheme -->
javascript:alert('XSS')
<!-- Level 6: Testing for DOM-based XSS in URL fragments -->
https://your-app.local/page#<img src=x onerror=alert('XSS')>

Alert box pops up? Confirmed XSS. Finding it is step one. Fixing it correctly is step two, and this is where teams get it wrong.

The fix is context-dependent output encoding. HTML-encode in HTML, JavaScript-encode inside a JS string, URL-encode inside a URL parameter. Modern frameworks handle this automatically -- in the default rendering mode. But every framework has an escape hatch: <%- %> in EJS, dangerouslySetInnerHTML in React, |safe in Django. Every one of those is a potential XSS hole. Any use of raw rendering should require a second pair of eyes in code review. No exceptions.

Layer a Content Security Policy header on top. A well-configured CSP blocks inline scripts from executing, which stops most XSS even when encoding has a gap. Not a replacement for proper encoding. A safety net. And the difference between a CSP that works and a CSP that is effectively unsafe-inline everywhere is the difference between having a safety net and thinking you have one.

SQL Injection Detection and Prevention

Still common. Still devastating.

The attack works because code concatenates user input directly into SQL query strings. The input is not sanitized, so the attacker alters the query's logic. Pull data from other tables, modify records, execute commands on the database server. The test is straightforward: you are looking for any endpoint where user input reaches a SQL query without parameterization. A single quote ' in an input field that triggers a database error is usually enough to confirm the vulnerability exists.

Python - Vulnerable SQL Query (DO NOT USE)
# VULNERABLE - Never concatenate user input into SQL!deflogin(username, password):
 query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
 cursor.execute(query)
 return cursor.fetchone()
# An attacker enters this as the username:# ' OR '1'='1' --# The query becomes:# SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = ''# The -- comments out the rest. '1'='1' is always true.# The attacker is now logged in as the first user in the table.# SAFE - Use parameterized queries insteaddeflogin_safe(username, password):
 query = "SELECT * FROM users WHERE username = %s AND password = %s"
 cursor.execute(query, (username, password))
 return cursor.fetchone()

Testing checklist:

The fix is always parameterized queries. Always. User input is treated as data, never as part of the SQL command structure. ORMs use parameterized queries internally, which is why they are generally safer. But the moment you drop to a raw query with string concatenation because the ORM felt limiting, you are back to square one.

Node.js - Parameterized Queries with Different Libraries
// Using mysql2 - parameterized queryconst [rows] = await connection.execute(
 'SELECT * FROM users WHERE email = ? AND status = ?',
 [userEmail, 'active']
);
// Using pg (PostgreSQL) - parameterized queryconst result = await pool.query(
 'SELECT * FROM products WHERE category = $1 AND price < $2',
 [category, maxPrice]
);
// Using Prisma ORM - safe by defaultconst user = await prisma.user.findFirst({
 where: {
 email: userEmail,
 status: 'active'
 }
});
// DANGER: Prisma raw queries CAN be vulnerable if misused// BAD: const result = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE name = '${name}'`);// GOOD: const result = await prisma.$queryRaw`SELECT * FROM users WHERE name = ${name}`;

A pattern that keeps showing up: the main application uses parameterized queries everywhere, but an admin panel or reporting tool built separately has raw concatenation. SQL injection hides in forgotten corners. Admin tools, CSV export endpoints, legacy code paths nobody touches. The main app is spotless. The internal dashboard is wide open. Check everything.

CSRF and Session Management Testing

Cross-Site Request Forgery. User is logged into your app. Visits a malicious page. That page submits a form to your endpoint. Browser attaches the session cookie automatically. Server sees a valid, authenticated request. User just changed their email address without knowing it.

Testing is straightforward -- create an HTML page that submits a form to one of your state-changing endpoints:

HTML - CSRF Test Page
<!-- Save this as csrf-test.html and open in browser while logged into your application --><html><body><h1>CSRF Test - Does this action succeed?</h1><!-- Test 1: Form-based CSRF --><form action="https://your-app.local/api/account/update-email"
 method="POST" id="csrf-form"><input type="hidden" name="email" value="[email protected]"/><input type="submit" value="Click to test CSRF"/></form><!-- Test 2: Auto-submitting form (simulates drive-by attack) --><!-- Uncomment to test automatic submission: --><!-- <script>document.getElementById('csrf-form').submit();</script> --><!-- Test 3: Image tag triggering GET request --><!-- If state-changing actions use GET, this is vulnerable: --><img src="https://your-app.local/api/account/delete?confirm=true"
 style="display:none"/></body></html>

Email change succeeds from a different origin? Vulnerable. Standard defense: CSRF token embedded in every form, verified on submission. Attacker's page cannot read the token (same-origin policy), cannot forge the request.

Django, Rails, and Laravel include CSRF protection by default. For SPAs with JSON APIs, the SameSite cookie attribute is the first line of defense. SameSite=Lax prevents the browser from sending cookies with cross-origin requests. Neutralizes most CSRF without tokens.

Session Management Checks

While you are testing CSRF, audit sessions too:

Authentication and Authorization Flaws

These are the bugs that make headlines. Broken authentication: attacker logs in as someone else. Broken authorization: regular user accesses admin functionality. Neither is exotic. Logic errors in code developers write themselves.

Authentication checks first. Account lockout after repeated failures? Password reset with time-limited tokens or does it email the actual password? Login error messages that distinguish "user not found" from "wrong password" -- they should not, that leaks which accounts exist.

Authorization testing is simpler than it sounds. Log in as User A, copy a request, replay it with User B's session cookie. If B sees A's data, that is an Insecure Direct Object Reference. Adapt this to your API. The pattern is the same everywhere:

Python - IDOR Authorization Test Script
import requests
# Test for Insecure Direct Object References (IDOR)# Log in as two different users and test cross-access
BASE_URL = "https://your-app.local/api"# Session for User A (regular user)
session_a = requests.Session()
session_a.post(f"{BASE_URL}/login", json={
 "email": "[email protected]",
 "password": "test-password-alice"
})
# Session for User B (different regular user)
session_b = requests.Session()
session_b.post(f"{BASE_URL}/login", json={
 "email": "[email protected]",
 "password": "test-password-bob"
})
# Endpoints to test - User A's resources accessed by User B
endpoints = [
 "/users/1/profile", # User A's profile"/users/1/orders", # User A's orders"/users/1/settings", # User A's settings"/admin/dashboard", # Admin-only page"/api/internal/metrics", # Internal endpoint
]
print("Testing authorization boundaries...\n")
for endpoint in endpoints:
 resp = session_b.get(f"{BASE_URL}{endpoint}")
 status = "PASS"if resp.status_code in [403, 404] else"FAIL"print(f"[{status}] {endpoint} - Status: {resp.status_code}")
# Expected output:# [PASS] /users/1/profile - Status: 403# [PASS] /users/1/orders - Status: 403# [PASS] /users/1/settings - Status: 403# [PASS] /admin/dashboard - Status: 403# [PASS] /api/internal/metrics - Status: 404

Every endpoint returning 200 to User B when it should return 403 is a bug. Five minutes. Catches one of the most damaging vulnerability classes.

Beyond IDOR, test privilege escalation. Can a regular user hit admin endpoints by changing the URL? Can they modify their own role by including a role field in a profile update request? Mass assignment. The server blindly trusts all fields instead of whitelisting what users are allowed to change.

JWTs deserve special attention. Common mistakes: not validating the signature, accepting the none algorithm, weak signing secrets, not checking expiration. A JWT without signature validation is a base64 string anyone can edit. And most JWT libraries have had vulnerabilities where algorithm confusion lets an attacker forge tokens entirely. Check your library version.

Automated Scanning Tools

Manual testing finds logic bugs. Automated tools catch the mechanical stuff fast. You need both.

OWASP ZAP (Zed Attack Proxy)

Free. Open-source. Acts as a proxy between your browser and app, intercepting requests for inspection and modification. The automated scanner crawls and tests for common vulnerabilities:

Bash - OWASP ZAP Automated Scan
# Run ZAP baseline scan against your local application# This is a quick, non-invasive scan suitable for CI/CD pipelines
docker run --rm -t zaproxy/zap-stable zap-baseline.py \
 -t https://your-app.local \
 -r zap-report.html
# Run a full active scan (more thorough, takes longer)
docker run --rm -t zaproxy/zap-stable zap-full-scan.py \
 -t https://your-app.local \
 -r zap-full-report.html
# Run ZAP against an API defined by an OpenAPI spec
docker run --rm -t zaproxy/zap-stable zap-api-scan.py \
 -t https://your-app.local/openapi.json \
 -f openapi \
 -r zap-api-report.html
# Integrate into CI/CD - fail the build if high-risk issues found
docker run --rm -t zaproxy/zap-stable zap-baseline.py \
 -t https://your-app.local \
 -c zap-config.conf \
 -I # Returns non-zero exit code on failures

Baseline scan catches passive issues -- missing headers, cookie flags. Full scan actively tests for XSS, SQL injection, path traversal. API scan targets REST APIs using an OpenAPI spec. Any of these can run in CI/CD.

Burp Suite

Industry standard. Free Community Edition includes the proxy and repeater. Professional adds the automated scanner and fuzzing. Particularly useful for SPAs where the interesting behavior lives in API calls you cannot see in the address bar.

Nikto

Basic web server scanner. Not sophisticated, but fast. Checks for dangerous defaults, outdated software, known vulnerable scripts. Catches things people forget -- exposed server-status pages, default admin credentials, backup files sitting in the webroot.

Use them together. Nikto for infrastructure sweep. ZAP for automated vulnerability scanning. Burp for manual testing of business logic that no scanner understands. Only run these against applications you own or have written permission to test.

What I Would Prioritize Differently

If you only have time for one test, make it the IDOR check. Write the script that replays User A's requests with User B's session. Automated scanners cannot find authorization bugs. They do not understand your business logic. You have to write this test yourself.

Security testing is not a one-time thing. Run OWASP ZAP in your CI pipeline, check your dependencies weekly with npm audit or Snyk, and assume that any user input you are not validating is being exploited right now.

Anurag Sinha

Anurag Sinha

Full Stack Developer & Technical Writer

Anurag is a full stack developer and technical writer. He covers web technologies, backend systems, and developer tools for the Codertronix community.