All lessons
SecurityIntermediate15 min readIn my stack

Web security and the OWASP Top 10

The most common web vulnerabilities, with real examples, and the defences that actually work.

  • OWASP Top 10
  • XSS / CSRF / SQLi
  • Secrets
  • Rate limiting
  • Audits
Step 1/5
A login form. Instead of a name, the hacker types ' OR 1=1 -- and smiles behind the sunglasses.

In 30 seconds

The OWASP Top 10 is a widely used list of the most critical web application security risks; the current edition is from 2025. Most real breaches come from a short list of mistakes: missing access checks, injection, misconfiguration, vulnerable dependencies and leaked secrets. Good security is a set of habits built into design, code review, configuration and deployment, not a final audit.

Why I use it

  • I use the OWASP Top 10 as a checklist because it covers the mistakes that actually cause breaches in business apps.
  • The products I build handle payments, personal data and tenant data, where one access-control bug becomes a serious incident.
  • Secure defaults in frameworks, headers and servers prevent whole classes of bugs at almost no cost.
  • Clients and auditors increasingly ask how security is handled, and a clear, concrete answer builds trust.

How it works

01The OWASP Top 10 in plain words

OWASP (the Open Worldwide Application Security Project) publishes the Top 10 from real vulnerability data and community surveys. The 2025 edition reads: A01 Broken Access Control (users reach data or actions they should not; SSRF is now part of this category); A02 Security Misconfiguration; A03 Software Supply Chain Failures (vulnerable or compromised dependencies, build tools and pipelines); A04 Cryptographic Failures; A05 Injection (SQL, command and also XSS); A06 Insecure Design; A07 Authentication Failures; A08 Software or Data Integrity Failures; A09 Security Logging and Alerting Failures; A10 Mishandling of Exceptional Conditions (errors that fail open or leak details). Use it as awareness and a review checklist, not as a complete standard; for detailed requirements, OWASP's Application Security Verification Standard (ASVS) and the API Security Top 10 go deeper.

02Broken access control, SSRF and CSRF

Broken access control is the most common serious flaw. Classic example: GET /api/orders/1043 returns any order, because the code checks that the user is logged in but not that the order is theirs. This is called IDOR (insecure direct object reference). The fix is to check ownership or permission on every request on the server, ideally through central policies, deny by default and return 404 for objects the user may not see. Server-side request forgery (SSRF) happens when your server fetches a URL supplied by the user, such as an image import, and an attacker points it at internal addresses or cloud metadata endpoints. Allow-list hosts and schemes, resolve and block private IP ranges, and disable redirects. Cross-site request forgery (CSRF) tricks a logged-in browser into sending a state-changing request; defend with SameSite cookies, CSRF tokens and checking the Origin header.

  • Authorise every object access on the server, not in the UI.
  • Deny by default; test with a second user account.
  • For URL fetching: allow-list, block private ranges, no redirects.

03Injection and XSS

Injection happens when user input is treated as code. In SQL injection, a search box containing ' OR 1=1 -- changes the meaning of a query built by string concatenation, leaking or deleting data. The fix is parameterised queries, where data is sent separately from the SQL, which every modern driver and ORM supports; be careful with raw query helpers and dynamic column names, which need allow-lists. The same principle applies to shell commands, LDAP and template engines. Cross-site scripting (XSS) is injection into HTML: an attacker stores a script in a review or profile name and it runs in other users' browsers, stealing sessions or performing actions. Frameworks such as React escape output by default; the danger zones are dangerouslySetInnerHTML, v-html, unescaped Blade output, and URLs in href attributes. Sanitise rich HTML with a proven library and add a Content Security Policy as a second layer.

ts
import { Pool } from "pg";
const db = new Pool();
 
// Vulnerable: input becomes part of the SQL text
// db.query("SELECT * FROM users WHERE email = '" + email + "'");
 
// Safe: input is sent as a parameter, never parsed as SQL
export async function findUser(email: string) {
  const { rows } = await db.query(
    "SELECT id, email FROM users WHERE email = $1",
    [email],
  );
  return rows[0] ?? null;
}

04Misconfiguration and secure headers

Many breaches need no clever exploit: debug mode left on in production, a public storage bucket, default admin passwords, verbose error pages with stack traces, directory listing, an exposed .env or .git folder, a database port open to the internet. Prevent these with configuration as code, environment-specific settings, and checks in the deploy pipeline. Security headers add browser-side protection. HTTP Strict Transport Security forces HTTPS. A Content Security Policy (CSP) tells the browser which sources of scripts, styles and images are allowed, which blocks most injected scripts; start in report-only mode, then enforce, and prefer nonces or hashes over 'unsafe-inline'. X-Content-Type-Options stops MIME sniffing, frame-ancestors in CSP prevents clickjacking, and Referrer-Policy and Permissions-Policy limit what leaks or is allowed. Mark cookies Secure, HttpOnly and SameSite.

nginx
# nginx: baseline security headers (adapt CSP sources to your app)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
server_tokens off;

05Secrets, dependencies and the supply chain

Secrets (API keys, database passwords, signing keys) belong in environment variables or a secrets manager, never in the repository, client bundles, logs or error messages. Give each secret the least privilege it needs, separate them per environment, and rotate them on a schedule and immediately after any suspected leak; a leaked key that cannot be rotated quickly is a design flaw. Add secret scanning to the repository and CI so mistakes are caught before they are pushed. Modern apps are mostly third-party code, which is why supply-chain failures are now in the top three. Commit lockfiles, run dependency audits (npm audit, composer audit, Dependabot or Renovate), pin and review CI actions, remove unused packages, and be suspicious of new packages with tiny download counts or install scripts. Keep frameworks and runtimes on supported versions.

06Rate limiting, bots, reviews and pentests

Every endpoint that can be abused needs limits: login, OTP and password-reset requests, search, contact forms, file uploads and public APIs. Rate limit by user, IP and API key, with stricter limits on authentication; lock or slow accounts after repeated failures, and add a CAPTCHA or proof-of-work challenge only where abuse is real. A CDN or WAF in front absorbs volumetric attacks and blocks known bad patterns. Uploads need their own care: check type by content rather than extension, cap sizes, guard against decompression bombs and serve files from a separate domain or through signed links. Then make security a process. Threat-model new features ('what could a malicious tenant do here?'), include security items in code review, run SAST and dependency scans in CI, and commission an external penetration test before major launches and periodically, fixing findings by severity.

  • Strict limits on login, OTP and reset flows.
  • Log security events (failed logins, permission denials) and alert on spikes.
  • Pentests find what you missed; they do not replace secure habits.

Use it when

  • Always: every web application and API needs these basics from the first release.
  • Before launching features that touch payments, personal data, uploads or permissions.
  • During code review and architecture review of new endpoints.
  • Before external audits, enterprise sales and compliance assessments.

Skip it when

  • Do not treat the Top 10 as a complete standard; use ASVS for detailed requirements.
  • Do not rely on obscurity (hidden URLs, disabled introspection) as your only defence.
  • Do not roll your own cryptography or password hashing; use proven libraries.

Alternatives

  • OWASP ASVS

    A detailed, levelled checklist of security requirements for verification.

  • OWASP API Security Top 10

    Focused on API risks such as object-level authorisation and resource consumption.

  • Managed WAF and bot protection

    Edge protection that blocks common attacks and abusive traffic before it reaches you.

  • Bug bounty programmes

    Continuous external testing, best after the basics and a pentest are done.

Best practices

  1. Check authorisation on the server for every object and action, denying by default.
  2. Use parameterised queries and framework escaping; sanitise any rich HTML.
  3. Send security headers, including an enforced Content Security Policy.
  4. Keep secrets out of code, scope them minimally and rotate them.
  5. Automate dependency and secret scanning in CI and keep runtimes supported.
  6. Rate limit authentication, OTP and abuse-prone endpoints.
  7. Log security events and test your defences with reviews and pentests.

Common mistakes

  • Hiding a button in the UI instead of enforcing the permission on the server.
  • Building SQL or shell commands with string concatenation.
  • Leaving debug mode, stack traces or admin panels exposed in production.
  • Committing secrets to Git and only deleting them in a later commit.
  • Ignoring dependency alerts until an actively exploited vulnerability forces an emergency.

Deep dive

07Passwords, sessions and tokens done properly

Store passwords with a slow, memory-hard hash. OWASP's Password Storage Cheat Sheet recommends Argon2id with at least 19 MiB of memory, 2 iterations and parallelism 1; scrypt when Argon2id is unavailable; bcrypt only for legacy systems, with a work factor of 10 or more and its 72-byte input limit in mind. Check new passwords against known breached lists rather than forcing odd composition rules. For sessions, opaque random IDs stored server-side are simple and instantly revocable. If you use JWTs, pin the accepted algorithm on the server (never trust the token's alg header), validate issuer, audience and expiry, keep access tokens short-lived, and rotate refresh tokens on every use, treating reuse of an old refresh token as theft and revoking the whole family. Regenerate session IDs after login and privilege changes to prevent session fixation.

ts
import argon2 from "argon2";
 
// OWASP minimum: Argon2id, 19 MiB memory, 2 iterations, parallelism 1
export function hashPassword(password: string) {
  return argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 19456, // KiB
    timeCost: 2,
    parallelism: 1,
  });
}
 
export function verifyPassword(hash: string, password: string) {
  return argon2.verify(hash, password); // parameters are read from the hash
}

08A strict Content Security Policy and Trusted Types

Allow-list policies (script-src with many CDN hosts) are often bypassable, because one allowed host may serve a JSONP endpoint or an old library an attacker can abuse. A strict policy relies on nonces or hashes instead: the server generates a fresh random nonce per response, puts it on every legitimate script tag and in the header. Adding 'strict-dynamic' lets scripts loaded by a trusted script run too, so tag managers and bundlers keep working, while injected markup without the nonce is blocked. Combine it with object-src 'none' and base-uri 'none' or 'self'. For DOM-based XSS, Trusted Types (require-trusted-types-for 'script') makes the browser refuse raw strings in dangerous sinks such as innerHTML, forcing code through a reviewed sanitising policy. Roll out in report-only mode first and fix violations.

http
Content-Security-Policy:
  script-src 'nonce-r4nd0mBase64Value' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  require-trusted-types-for 'script';
  report-to csp-endpoint

09SSRF in depth: DNS rebinding and cloud metadata

Blocking private IPs in the URL string is not enough. Attackers use alternative notations (decimal or IPv6-mapped addresses), redirects to internal hosts, and DNS rebinding, where a hostname resolves to a public IP during your check and to 127.0.0.1 or 169.254.169.254 a moment later when you connect. Robust defences resolve the hostname once, validate every resolved address against blocked ranges (loopback, private, link-local, unique local IPv6), then connect to that exact IP while sending the original Host header, and re-validate on each redirect or disable redirects. Better still, route all user-driven fetches through an egress proxy that enforces the policy centrally and has no access to internal networks. On AWS, require IMDSv2, whose session token must be obtained with a PUT request, which blocks most simple SSRF attempts against instance credentials.

  • Validate resolved IPs, not the URL text; pin the connection to them.
  • Block loopback, RFC 1918, link-local and IPv6 unique-local ranges.
  • Send user-driven fetches through an isolated egress proxy.

10Cookies, CORS and browser isolation

Cookie attributes are a cheap, strong layer. Secure and HttpOnly keep session cookies off plain HTTP and away from JavaScript. SameSite=Lax, the default in modern browsers when unspecified, blocks most cross-site POSTs; use Strict for highly sensitive cookies and None; Secure only when you genuinely need cross-site use. The __Host- prefix forces Secure, Path=/ and no Domain attribute, so a compromised subdomain cannot overwrite the cookie. CORS is often misread: it relaxes the same-origin policy, it does not protect your API. Never reflect arbitrary Origin values together with Access-Control-Allow-Credentials: true; keep an explicit allow-list. For stronger isolation, Cross-Origin-Opener-Policy: same-origin separates your window from cross-origin popups, and Cross-Origin-Resource-Policy limits which sites may embed your resources.

http
Set-Cookie: __Host-session=9f1c...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600
 
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
 
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-site

Real-world scenarios

01

An API key is pushed to a public repository

Problem

A developer committed a payment provider secret key to a public repository. It was removed in the next commit an hour later.

How to solve it

Assume it is compromised: bots scan public repositories within minutes. Revoke or rotate the key immediately in the provider's dashboard, deploy the new key through environment variables or the secrets manager, and check the provider's logs for activity from unknown IPs during the exposure window. Then clean history if the repository must stay public, knowing that this does not undo exposure. Add secret scanning with push protection, a pre-commit hook, and narrower restricted keys per service so a future leak has a smaller blast radius.

02

IDOR found in a pentest

Problem

A penetration test shows that changing the invoice ID in /api/invoices/{id}/pdf downloads other customers' invoices. The endpoint was added quickly for a mobile app.

How to solve it

Fix the endpoint first: load the invoice through a query scoped by the current user's account, return 404 when not found, and deploy. Then search the codebase for the same pattern, any handler that loads by ID without scope, and route them through a policy or scoped repository. Add an automated authorisation test suite that calls every endpoint as a second user and expects 404 or 403. Review access logs for the period the endpoint existed to see whether it was exploited, and inform affected customers if it was.

03

Rolling out CSP on an existing site

Problem

A marketing site with analytics, a chat widget and inline scripts has no CSP. Enforcing a policy in one step would break features.

How to solve it

Start with Content-Security-Policy-Report-Only and a reporting endpoint, using a nonce-based policy with 'strict-dynamic'. Add the per-response nonce to legitimate inline and external scripts through the framework (Next.js middleware or server templates can do this). Collect reports for a week, fix or remove scripts that violate it, and move inline event handlers into script files. Then enforce, keep reporting on, and add a CI check or code review rule for any new third-party script.

Cheat sheet

Hash passwords
Argon2id, m=19 MiB, t=2, p=1 (OWASP minimum)
Session cookie
__Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Strict CSP core
script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Test CSP without breaking
Content-Security-Policy-Report-Only: ...
Block DOM XSS sinks
require-trusted-types-for 'script'
Audit Node dependencies
npm audit --omit=dev
Audit PHP dependencies
composer audit
Find secrets in Git history
gitleaks detect --source .
Require IMDSv2 on AWS
aws ec2 modify-instance-metadata-options --instance-id i-0abc --http-tokens required
Check headers quickly
curl -sI https://example.com

In my real projects

  • In the POS SaaS, authentication uses RS256 JWTs re-validated per request with a denylist, 2FA for staff and operators, and scoped API keys with live/test modes, rate limits and rotation.
  • Media uploads there are content-sniffed, protected against decompression bombs and served through signed private links, and outgoing webhooks are HMAC-signed.
  • My server baseline is SSH keys only, a firewall allowing only ports 22, 80 and 443, databases never exposed publicly, fail2ban and unattended upgrades, and HTTPS everywhere.
  • On the pet-supplies platform, search is rate limited and admin login uses OTP; in the shared packages, raw card data is kept out of the persisted cart store.

Questions & answers

Q1What is the most common serious vulnerability in web apps?

Broken access control, which is first in the OWASP Top 10. Typically an API checks that the user is authenticated but not that they are allowed to access the specific object, so changing an ID in the URL returns someone else's order or invoice. It is common because frameworks handle authentication for you but cannot know your business rules. I prevent it with central authorisation policies called on every request, scoping queries by user or tenant, denying by default and returning 404 for inaccessible objects, plus automated tests that use a second account to try forbidden access.

Q2How do you prevent SQL injection?

By never building SQL with string concatenation of user input. Use parameterised queries or prepared statements, where the SQL text and the values travel separately, so input cannot change the query's structure. ORMs and query builders do this by default, but their raw-query helpers and dynamic identifiers such as sort columns are where bugs creep back in; those need allow-lists. As defence in depth, the database user should have only the privileges the app needs, and errors should not reveal SQL details. Code review and static analysis catch most remaining cases.

Q3What is a Content Security Policy and how do you roll it out?

CSP is an HTTP header that tells the browser which sources of scripts, styles, images, frames and connections are allowed. If an attacker injects a script tag, the browser refuses to run it unless it matches the policy, which makes CSP a strong second layer against XSS. I roll it out gradually: start with Content-Security-Policy-Report-Only and a collector, fix violations from legitimate sources, then enforce. Prefer nonces or hashes for inline scripts over 'unsafe-inline', add object-src 'none', base-uri 'self' and frame-ancestors to prevent clickjacking, and review it whenever third-party scripts change.

Q4XSS vs CSRF: what is the difference?

XSS, cross-site scripting, means an attacker gets their script running inside your site, for example through an unescaped comment. That script runs with the user's privileges on your origin, so it can read the page and make requests. CSRF, cross-site request forgery, means another site makes the user's browser send a request to your site, riding on their cookies, without running code on your origin. XSS is prevented by output escaping, sanitising and CSP. CSRF is prevented by SameSite cookies, anti-CSRF tokens and Origin checks. Note that XSS defeats CSRF protection, because the script can read tokens.

Q5How do you manage secrets in a project?

Secrets never go into the repository, the client bundle or logs. They live in environment variables injected at deploy time or, better, in a secrets manager with access control and audit logs. Each environment has its own secrets, each secret has the minimum permissions it needs, and rotation is a routine, documented operation. Secret scanning runs in pre-commit hooks and CI. If a secret leaks, I rotate it first and investigate second, because removing it from Git history does not help once it has been pushed; anyone may already have copied it.

Q6What would you check in a quick security review of a web app?

I start with access control: can user A reach user B's resources by changing IDs, and are admin endpoints protected on the server? Then authentication: password hashing, rate limiting on login and OTP, session and token handling. Then input handling: parameterised queries, output escaping, file upload validation and any server-side URL fetching. Next configuration: debug mode, error pages, exposed files, security headers, CORS and cookie flags. Then secrets in the repository and dependency audit results. Finally logging: are failed logins and permission denials recorded and alerted? That covers most of the Top 10 quickly.

Senior-level questions

S1JWTs or server-side sessions: what would you choose and why?

For a first-party web app, server-side sessions with an opaque ID in a __Host- cookie are usually the safer default: instant revocation, small cookies and no token parsing pitfalls. JWTs help when many services must verify identity without calling a central store, or for third-party API access. Their costs: revocation needs short expiry plus a denylist or per-request revalidation, algorithm and key handling must be strict, and storing them in localStorage exposes them to XSS. A common hybrid is short-lived access tokens, around minutes, with rotating refresh tokens kept in HttpOnly cookies and reuse detection. Whatever the choice, sign with asymmetric keys when several services verify, rotate keys with a key ID, and log authentication events for detection.

S2How do you build security into the delivery pipeline without slowing teams down?

Automate the checks that are cheap and precise, and keep human attention for design. In the pipeline: secret scanning with push protection, dependency audits that fail only on high-severity issues with available fixes, SAST tuned to a small, trusted rule set, lockfile and pinned CI actions, and container image scanning. Block merges on findings that matter; send the rest to a backlog with owners. Before coding, run a short threat-modelling session for features touching auth, payments, uploads or tenant boundaries. Provide secure defaults in shared libraries, such as scoped repositories, an HTTP client with SSRF protection and header middleware, so the safe path is the easy path. The trade-off is setup and tuning effort; untuned tools create noise that developers learn to ignore.

S3How would you respond to a critical vulnerability disclosed in a dependency you use?

Treat it as an incident with a clock. First, determine exposure: is the vulnerable package in production builds, at an affected version, and is the vulnerable code path reachable with attacker-controlled input? A software bill of materials or lockfile search answers the first two in minutes. If exposed, mitigate fastest: upgrade or patch, or apply a temporary control such as a WAF rule, disabling a feature or blocking an input pattern. Deploy through the normal pipeline but expedited, and verify the fix. Check logs for exploitation attempts since disclosure, and rotate secrets if code execution was possible. Afterwards, review why detection took as long as it did and whether automated update tooling would have caught it earlier.

Glossary

IDOR
Insecure direct object reference: accessing another user's object by changing an identifier.
SSRF
Server-side request forgery: tricking a server into fetching internal or unintended URLs.
CSRF
A forged cross-site request that rides on the victim's cookies to change state.
XSS
Cross-site scripting: attacker-controlled script running in your site's pages.
CSP nonce
A random per-response value that marks which scripts the browser may execute.
Trusted Types
A browser feature that blocks raw strings in dangerous DOM sinks unless passed through a policy.
Argon2id
A memory-hard password hashing algorithm recommended by OWASP for storing passwords.
DNS rebinding
Making a hostname resolve to a safe IP at check time and an internal IP later.
Supply chain attack
Compromising software through its dependencies, build tools or delivery pipeline.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.