All articles
Security#owasp#appsec#checklist#nodejs

Web app security checklist: the OWASP Top 10 in practice

A practical, code-level walk through the OWASP Top 10: the checks we run on every web app for access control, injection, validation, security headers, dependencies and logging.

FX

Codestria Engineering

Engineering team

5 min read

Most teams know the OWASP Top 10 exists. Fewer can say what it means for the endpoint they are about to merge. The list describes categories of risk, not tasks, so it is easy to read, nod along and ship an API where any logged-in user can fetch any invoice by changing an ID in the URL.

This article turns the categories into concrete checks you can run against a real codebase. The 2025 edition reshuffled the list (software supply chain failures and mishandling of exceptional conditions are new entries, and SSRF was folded into broken access control), but the engineering habits that prevent these issues have not changed. Examples are in TypeScript and Node.js, but every check translates directly to other stacks.

Use it as a review checklist before launch and again whenever you add a major feature. None of these checks replace a penetration test for high-risk systems, but they close the gaps that automated scanners and attackers find first, and they are cheap to build into everyday code review.

Broken access control: check ownership, not just login

This is the category that causes the most real-world damage, and it is almost always the same bug: an endpoint verifies that a user is authenticated, then loads a record by ID without checking that the record belongs to them. Attackers find it by incrementing IDs. UUIDs make guessing harder but are not access control.

  • Scope every query by the tenant or owner: WHERE id = $1 AND account_id = $2, not a fetch followed by an if.
  • Deny by default. New routes should require auth and an explicit permission unless deliberately marked public.
  • Return 404, not 403, for resources the caller cannot see, so you do not confirm they exist.
  • Check function-level permissions too: an admin-only action must verify the role on the server, not rely on the button being hidden.
  • Restrict outbound fetches of user-supplied URLs to an allowlist to avoid SSRF into internal services or cloud metadata endpoints.

The most reliable way to enforce this is to make the safe path the easy one. Put the tenant ID into a request-scoped context in middleware and have your data-access layer require it, or use Postgres row-level security so a query without the right tenant setting returns nothing. Then write tests that log in as user A and request user B's resources; these tests are cheap and catch regressions that code review routinely misses.

src/routes/invoices.ts
import { Router } from "express";
import { requireAuth, requirePermission } from "../auth/middleware";
import { db } from "../db";

export const invoices = Router();

invoices.get("/invoices/:id", requireAuth, requirePermission("invoices:read"), async (req, res) => {
  // Ownership is part of the query, so a foreign ID simply finds nothing
  const { rows } = await db.query(
    "SELECT id, number, total, status FROM invoices WHERE id = $1 AND account_id = $2",
    [req.params.id, req.user.accountId],
  );
  if (rows.length === 0) return res.status(404).json({ error: "Not found" });
  res.json(rows[0]);
});

Injection and input validation

Injection happens when untrusted input is interpreted as code: SQL, shell commands, LDAP filters or HTML. The fix is structural, not a matter of escaping harder. Use parameterised queries for every database call, pass argument arrays rather than strings to child processes, and let your template engine or React escape output by default.

Validation is the companion habit. Parse every request body, query string and header you rely on against a schema at the boundary, reject anything unexpected, and pass typed data inward. This shuts down whole classes of bugs, including mass assignment, where a client sets role: 'admin' because the handler spread the body straight into an update.

src/routes/profile.ts
import { z } from "zod";
import { Router } from "express";
import { requireAuth } from "../auth/middleware";
import { db } from "../db";

const UpdateProfile = z
  .object({
    displayName: z.string().trim().min(1).max(80),
    timezone: z.string().max(64),
    marketingOptIn: z.boolean(),
  })
  .strict(); // unknown keys such as "role" are rejected, not ignored

export const profile = Router();

profile.patch("/me", requireAuth, async (req, res) => {
  const parsed = UpdateProfile.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: "Invalid input", issues: parsed.error.issues });
  }
  const { displayName, timezone, marketingOptIn } = parsed.data;
  await db.query(
    "UPDATE users SET display_name = $1, timezone = $2, marketing_opt_in = $3 WHERE id = $4",
    [displayName, timezone, marketingOptIn, req.user.id],
  );
  res.status(204).end();
});

Security misconfiguration and headers

Misconfiguration covers everything that is insecure by setting rather than by code: verbose error pages in production, default credentials, permissive CORS, public storage buckets, debug endpoints left enabled. A short list of response headers removes a lot of browser-side risk for very little effort.

nginx/security-headers.conf
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" 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;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;

# Do not advertise versions
server_tokens off;

Treat the CSP as a starting point. Most apps need to add script or connect sources for analytics, payments or APIs. Roll it out with Content-Security-Policy-Report-Only first, watch the reports, then enforce. For CORS, list exact origins; never reflect the request's Origin back alongside Access-Control-Allow-Credentials: true.

Authentication, crypto and secrets

  • Hash passwords with Argon2id or bcrypt, never a fast hash like SHA-256.
  • Rate-limit login, signup, password reset and OTP endpoints per account and per IP.
  • Return the same response for unknown email and wrong password to avoid account enumeration.
  • Offer MFA, and require it for admin accounts.
  • Keep secrets in a secrets manager or the platform's encrypted environment variables, never in the repository, and rotate anything that has ever been committed.
  • Enforce TLS everywhere and encrypt sensitive columns or backups where regulation or risk demands it.

Supply chain, logging and error handling

Your dependencies run with the same privileges as your code. Commit a lockfile, install with npm ci in CI so builds use exactly what was reviewed, enable automated dependency updates, and fail the build on known critical vulnerabilities. Pin third-party GitHub Actions to a commit SHA, and be cautious with packages that run install scripts.

Integrity matters beyond packages, too. Verify signatures on webhooks and file uploads you process, avoid deserialising untrusted data into objects that can execute code, and make sure your deployment pipeline only ships artifacts it built itself. A compromised CI token is as damaging as a compromised server, so give workflows the minimum permissions they need.

On the other end, you cannot respond to what you cannot see. Log authentication events, permission denials, and changes to roles and billing with the user, IP and request ID, and alert on unusual spikes. Do not log passwords, tokens or full card numbers. Finally, handle errors deliberately: fail closed when an authorization check throws, and return generic messages to clients while keeping stack traces in your logs.

Key takeaways

  • Scope every data access by owner or tenant inside the query; authentication alone is not authorization.
  • Validate input against strict schemas at the boundary and use parameterised queries everywhere.
  • Ship security headers and a CSP, starting in report-only mode.
  • Use slow password hashes, rate limits and MFA, and keep secrets out of the repo.
  • Treat dependencies, logs and error paths as part of your attack surface.

Want a second pair of eyes before launch? Our backend team reviews and hardens APIs, auth and infrastructure. Talk to us.

Frequently asked questions

What is the most common vulnerability in web apps?

Broken access control sits at the top of the OWASP list: users reaching data or actions that belong to someone else, usually because an endpoint checks authentication but not ownership.

Does using an ORM prevent SQL injection?

Mostly, as long as you use its parameterised APIs. Raw query helpers and string-built filters or ORDER BY clauses can still be injectable.

Which security headers should every site send?

Strict-Transport-Security, a Content-Security-Policy, X-Content-Type-Options: nosniff, a Referrer-Policy and frame-ancestors (or X-Frame-Options) to prevent clickjacking.

Found this useful? Share it with your team.

LinkedInX

Need help with backend & apis?

Secure, scalable backends and APIs with Node.js, Nest.js and Python, built for real traffic.

Learn more