Inapoi la Blog
Security
·13 min read

Securing Your Web Application: A Practical OWASP Guide for 2026

Security breaches cost an average of $4.45 million. This hands-on guide walks through the most critical web application vulnerabilities and exactly how to prevent them in modern stacks.

Why This Still Matters

The average cost of a data breach hit $4.45 million in 2023 and has only climbed since. Yet the vulnerabilities behind most breaches are not novel zero-days — they are the same classes of flaws the OWASP Top 10 has been cataloguing for over two decades. Broken access control, injection, cryptographic misuse. The attacks are well-understood. The defenses are well-documented. And still, teams ship vulnerable code every single day.

This guide is not a theoretical overview. It is a hands-on walkthrough of the most critical vulnerability classes in the current OWASP Top 10, with real code examples showing vulnerable patterns alongside their secure counterparts. Everything here targets modern JavaScript and TypeScript stacks — the kind of applications most teams are actually building in 2026.

If you ship web applications, this is your baseline.

Broken Access Control (OWASP #1)

Broken access control has held the number-one spot on the OWASP Top 10 since 2021, and for good reason. It covers a wide surface: horizontal privilege escalation (accessing another user's data), vertical privilege escalation (performing admin actions as a regular user), Insecure Direct Object References (IDOR), and missing function-level access control.

The root cause is almost always the same: the application trusts client-supplied identifiers without verifying authorization on the server.

The Vulnerable Pattern

// VULNERABLE: Trusts the userId from the request without authorization check
app.get("/api/users/:userId/invoices", async (req, res) => {
  const invoices = await db.query(
    "SELECT * FROM invoices WHERE user_id = $1",
    [req.params.userId]
  );
  res.json(invoices);
});

An attacker changes /api/users/42/invoices to /api/users/43/invoices and sees someone else's billing data. There is no check that the authenticated user is authorized to view that resource.

The Secure Pattern

// SECURE: Derive the user identity from the session, not the URL
app.get("/api/invoices", authenticate, async (req, res) => {
  const invoices = await db.query(
    "SELECT * FROM invoices WHERE user_id = $1",
    [req.user.id] // from verified session/JWT — never from request params
  );
  res.json(invoices);
});

Implementing RBAC Middleware

For vertical privilege escalation, you need Role-Based Access Control enforced at the middleware layer, not scattered across individual route handlers:

type Role = "viewer" | "editor" | "admin";

function requireRole(...allowed: Role[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user || !allowed.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

// Usage
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.put("/api/posts/:id", authenticate, requireRole("editor", "admin"), updatePost);

Row-Level Security in PostgreSQL

If your stack includes PostgreSQL, Row-Level Security (RLS) provides a database-level safety net that catches authorization bugs your application code might miss:

-- Enable RLS on the invoices table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Users can only see their own invoices
CREATE POLICY invoices_select ON invoices
  FOR SELECT
  USING (user_id = current_setting('app.current_user_id')::int);

-- Set the user context at the start of each request
SET LOCAL app.current_user_id = '42';

Even if an application bug leaks a query without a WHERE user_id = ... clause, the database itself will filter the results. This is defense in depth — the principle that no single layer should be solely responsible for security.

Injection (OWASP #3)

Injection vulnerabilities occur when untrusted data is sent to an interpreter as part of a command or query. While SQL injection is the most well-known variant, modern applications face NoSQL injection and command injection as well.

A common misconception: "We use an ORM, so we are safe from injection." This is false. ORMs protect you when you use their query builder correctly. The moment you drop down to raw queries — and every non-trivial application eventually does — you are back to square one.

SQL Injection — Even With ORMs

// VULNERABLE: String interpolation in a raw query
const results = await prisma.$queryRawUnsafe(
  `SELECT * FROM products WHERE name LIKE '%${searchTerm}%'`
);

// SECURE: Parameterized query
const results = await prisma.$queryRaw`
  SELECT * FROM products WHERE name LIKE ${"%" + searchTerm + "%"}
`;

The difference is subtle but critical. The first example concatenates user input directly into the SQL string. The second uses a tagged template literal that Prisma converts into a parameterized query — the database treats the input as data, never as executable SQL.

NoSQL Injection

MongoDB is not immune. When query operators are passed through unsanitized request bodies, attackers can manipulate query logic:

// VULNERABLE: Accepts raw MongoDB operators from user input
app.post("/api/login", async (req, res) => {
  const user = await db.collection("users").findOne({
    username: req.body.username,
    password: req.body.password, // attacker sends { "$ne": "" }
  });
  if (user) return res.json({ token: createToken(user) });
});

// SECURE: Explicitly cast inputs to expected types
app.post("/api/login", async (req, res) => {
  const username = String(req.body.username);
  const password = String(req.body.password);
  const user = await db.collection("users").findOne({ username });
  if (user && await bcrypt.compare(password, user.passwordHash)) {
    return res.json({ token: createToken(user) });
  }
  res.status(401).json({ error: "Invalid credentials" });
});

Command Injection

Any time your application spawns a shell process with user-controlled input, you have a command injection surface:

// VULNERABLE: Shell injection via unsanitized filename
app.get("/api/download", (req, res) => {
  exec(`zip -r archive.zip uploads/${req.query.filename}`, callback);
  // attacker sends filename="; rm -rf /"
});

// SECURE: Use execFile with argument arrays (no shell interpolation)
import { execFile } from "child_process";

app.get("/api/download", (req, res) => {
  const filename = path.basename(req.query.filename); // strip path traversal
  execFile("zip", ["-r", "archive.zip", `uploads/${filename}`], callback);
});

Key takeaway: Never concatenate user input into queries or commands. Use parameterized queries, type coercion, and argument arrays. Validate and sanitize at every trust boundary.

Cryptographic Failures (OWASP #2)

Cryptographic failures cover a broad category: weak hashing algorithms for passwords, plaintext secrets in source code, poor TLS configuration, and inadequate encryption of data at rest.

Password Hashing: Stop Using MD5 and SHA-1

MD5 and SHA-1 are not password hashing algorithms. They are fast message digests designed for integrity checking. An attacker with a modern GPU can compute billions of MD5 hashes per second. Use purpose-built password hashing functions with configurable work factors:

import { hash, verify } from "argon2";

// Hashing a password (Argon2id — recommended by OWASP)
const passwordHash = await hash(password, {
  type: 2, // Argon2id
  memoryCost: 65536, // 64 MB
  timeCost: 3,
  parallelism: 4,
});

// Verifying a password
const isValid = await verify(passwordHash, candidatePassword);

If Argon2 is not available in your environment, bcrypt with a cost factor of at least 12 is an acceptable alternative. Never roll your own hashing scheme.

Secret Management

Hardcoded secrets in source code are one of the most common findings in security audits:

// VULNERABLE: Hardcoded secret (will end up in git history)
const stripe = new Stripe("sk_live_abc123realkey");

// SECURE: Environment variable with validation at startup
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY;
if (!STRIPE_KEY) {
  throw new Error("STRIPE_SECRET_KEY is required");
}
const stripe = new Stripe(STRIPE_KEY);

But environment variables alone are not enough for production systems. Use a dedicated secret manager — HashiCorp Vault, AWS Secrets Manager, or Doppler — that provides audit logging, rotation, and access control for secrets. At minimum, ensure your .env files are in .gitignore and use something like dotenv-vault or git-secrets as a pre-commit hook to prevent accidental commits of sensitive values.

Enforce HSTS

HTTP Strict Transport Security tells browsers to always use HTTPS for your domain, preventing protocol downgrade attacks:

// Express middleware
app.use((req, res, next) => {
  res.setHeader(
    "Strict-Transport-Security",
    "max-age=63072000; includeSubDomains; preload"
  );
  next();
});

Submit your domain to the HSTS preload list for maximum protection. Once preloaded, browsers will refuse to connect over HTTP even on the very first visit.

Security Misconfiguration (OWASP #5)

Security misconfiguration is the vulnerability class that haunts operations teams. Default credentials on admin panels, verbose error messages leaking stack traces to users, publicly accessible cloud storage buckets, and unnecessary services exposed to the internet.

Verbose Error Messages

// VULNERABLE: Leaks internal details to the client
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack, // attacker sees file paths, dependency versions
    query: req.query,
  });
});

// SECURE: Generic message to client, detailed logging server-side
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  const errorId = crypto.randomUUID();
  logger.error({ errorId, err, req: { method: req.method, url: req.url } });
  res.status(500).json({
    error: "An internal error occurred",
    errorId, // client can reference this for support
  });
});

Security Headers Checklist

Every response from your application should include a set of security headers. Here is the baseline:

// Comprehensive security headers middleware
app.use((req, res, next) => {
  // Prevent clickjacking
  res.setHeader("X-Frame-Options", "DENY");

  // Block MIME-type sniffing
  res.setHeader("X-Content-Type-Options", "nosniff");

  // Control referrer information leakage
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");

  // Restrict browser features
  res.setHeader(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), payment=()"
  );

  // Content Security Policy (see detailed section below)
  res.setHeader("Content-Security-Policy", cspPolicy);

  // HSTS
  res.setHeader(
    "Strict-Transport-Security",
    "max-age=63072000; includeSubDomains; preload"
  );

  next();
});

Content Security Policy (CSP)

CSP is your strongest defense against Cross-Site Scripting (XSS). A well-configured policy tells the browser exactly which sources of content are legitimate:

const cspPolicy = [
  "default-src 'self'",
  "script-src 'self' 'nonce-${nonce}'", // nonce-based for inline scripts
  "style-src 'self' 'unsafe-inline'",   // ideally use nonces for styles too
  "img-src 'self' data: https://cdn.example.com",
  "font-src 'self' https://fonts.gstatic.com",
  "connect-src 'self' https://api.example.com",
  "frame-ancestors 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  "upgrade-insecure-requests",
].join("; ");

Start with a strict policy and use report-only mode (Content-Security-Policy-Report-Only) during rollout to catch violations before they break functionality. Tools like Report URI can aggregate and analyze CSP violation reports.

Infrastructure as Code

Manual server configuration drifts. Use Infrastructure as Code (Terraform, Pulumi, AWS CDK) to define your security configuration declaratively. This gives you version control, peer review, and reproducibility for security-critical settings like firewall rules, IAM policies, and storage bucket ACLs.

Server-Side Request Forgery — SSRF (OWASP #10)

SSRF occurs when an application fetches a URL supplied by the user without adequate validation. The server becomes a proxy, and attackers use it to reach internal services that are not exposed to the public internet — metadata endpoints, internal APIs, databases.

Common attack surfaces include webhook handlers, URL preview/unfurling features, PDF generation from URLs, and image upload via URL.

The Vulnerable Pattern

// VULNERABLE: Fetches any URL the user provides
app.post("/api/webhook-test", async (req, res) => {
  const { url } = req.body;
  const response = await fetch(url); // attacker sends http://169.254.169.254/latest/meta-data/
  const data = await response.text();
  res.json({ status: response.status, body: data });
});

An attacker targeting an AWS-hosted application could use this to read the instance metadata service at 169.254.169.254, extracting IAM credentials.

The Secure Pattern

import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";

const ALLOWED_PROTOCOLS = ["https:"];
const BLOCKED_HOSTS = ["metadata.google.internal", "169.254.169.254"];

async function validateUrl(input: string): Promise<URL> {
  const parsed = new URL(input);

  // Protocol allowlist
  if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
    throw new Error("Only HTTPS URLs are allowed");
  }

  // Block known metadata endpoints
  if (BLOCKED_HOSTS.includes(parsed.hostname)) {
    throw new Error("Blocked host");
  }

  // Resolve DNS and check for private/internal IPs
  const addresses = await dns.resolve4(parsed.hostname);
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new Error("URL resolves to a private IP address");
    }
  }

  return parsed;
}

app.post("/api/webhook-test", async (req, res) => {
  try {
    const validatedUrl = await validateUrl(req.body.url);
    const response = await fetch(validatedUrl.toString(), {
      redirect: "manual", // do not follow redirects (they can bypass checks)
      signal: AbortSignal.timeout(5000),
    });
    res.json({ status: response.status });
  } catch (err) {
    res.status(400).json({ error: "Invalid URL" });
  }
});

Critical details: Always resolve DNS and check the resulting IP address against private ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x, 169.254.x.x). Set redirect: "manual" to prevent redirect-based bypasses where the initial URL passes validation but redirects to an internal address. Apply a timeout to prevent slowloris-style resource exhaustion.

At the infrastructure level, network segmentation is your strongest defense. Application servers should not have network access to metadata endpoints or internal admin services. Use VPC configurations, security groups, and service mesh policies to enforce this.

Software Supply Chain Security

Your application is only as secure as its dependencies, and modern JavaScript applications typically have hundreds of them. Supply chain attacks — dependency confusion, typosquatting (e.g., lodash vs 1odash), and compromised maintainer accounts — have moved from theoretical to routine.

Dependency Auditing

Run npm audit or yarn audit in every CI pipeline. But do not stop there — these tools only check against known vulnerability databases. Complement them with deeper scanning:

# Check for known vulnerabilities
npm audit --audit-level=high

# Use Snyk for deeper analysis (includes transitive dependencies)
npx snyk test

# Scan container images for OS-level vulnerabilities
trivy image your-app:latest

# Generate a Software Bill of Materials (SBOM)
npx @cyclonedx/cyclonedx-npm --output-file sbom.json

Automated Dependency Updates

Configure Dependabot or Renovate to open pull requests for dependency updates automatically. The key is to combine this with a solid test suite — automated updates are only safe if your CI pipeline catches regressions.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "security-team"
    labels:
      - "dependencies"
      - "security"

Lock File Integrity

Your package-lock.json or yarn.lock is a security artifact. It pins the exact versions and integrity hashes of every dependency. Always commit your lock file and use npm ci (not npm install) in CI pipelines to ensure builds use exactly the resolved versions. Review lock file changes in PRs — unexpected registry URL changes or new packages are red flags.

An SBOM (Software Bill of Materials) provides a complete inventory of every component in your application. Generating one is increasingly a compliance requirement and makes vulnerability response dramatically faster when a new CVE is published.

Security Testing Tools

Manual code review is necessary but not sufficient. Integrate automated security testing into your development workflow:

  • OWASP ZAP — Free, open-source dynamic application security testing (DAST). Run it against your staging environment in CI to catch common vulnerabilities automatically. The ZAP baseline scan is a good starting point for API testing.
  • Burp Suite — The industry-standard tool for manual penetration testing. The Community Edition is free and powerful enough for most assessments. Use it for authentication testing, session management analysis, and business logic flaws.
  • Snyk — Integrates into your IDE, CI/CD pipeline, and container registry. Covers dependency vulnerabilities, code analysis (SAST), container scanning, and Infrastructure as Code misconfigurations.
  • Trivy — Open-source scanner for container images, file systems, and git repositories. Fast, comprehensive, and easy to integrate into CI. Catches OS package vulnerabilities that application-level scanners miss.
  • ESLint Security Pluginseslint-plugin-security and eslint-plugin-no-unsanitized catch dangerous patterns at development time, before code is even committed.

The ideal pipeline runs static analysis (SAST) on every commit, dependency scanning on every PR, and dynamic analysis (DAST) on every staging deployment.

Security Baseline Checklist

Use this as a starting point for your team. Every item should be verified, not assumed:

Authentication and Access Control

  • All endpoints enforce authentication (no accidental public routes)
  • Authorization checked server-side for every request (never trust client-side role checks)
  • RBAC or ABAC implemented at the middleware layer
  • Row-level security enabled for multi-tenant data
  • Session tokens are invalidated on logout and have reasonable expiry

Data Protection

  • Passwords hashed with Argon2id or bcrypt (cost factor 12+)
  • No secrets in source code or git history
  • Secrets managed through a dedicated vault or secret manager
  • HSTS enabled with preload, minimum max-age of two years
  • TLS 1.2+ enforced, TLS 1.0/1.1 disabled

Input Handling

  • All database queries use parameterized statements
  • User input validated and sanitized at every trust boundary
  • File uploads validated by content type (not just extension)
  • URL inputs validated with DNS resolution and private IP blocking

Security Headers

  • Content-Security-Policy configured and enforced
  • X-Frame-Options: DENY set
  • X-Content-Type-Options: nosniff set
  • Referrer-Policy configured
  • Permissions-Policy restricts unnecessary browser features

Supply Chain and Infrastructure

  • npm audit / yarn audit runs in CI with a failure threshold
  • Dependabot or Renovate configured for automated dependency updates
  • Lock files committed and CI uses npm ci
  • Container images scanned with Trivy or equivalent
  • Error messages do not expose stack traces or internal details
  • Infrastructure defined as code with security configurations version-controlled

Monitoring

  • Authentication failures are logged and monitored
  • Rate limiting in place for authentication endpoints
  • CSP violation reports collected and reviewed
  • SBOM generated and maintained

Making Security Part of the Process

Security is not a phase at the end of a project. It is a continuous practice woven into design, development, code review, testing, and operations. The vulnerabilities in this guide are not edge cases — they are the most common attack vectors responsible for the majority of breaches.

The OWASP Top 10 is a starting point, not a finish line. Threat modeling during design, security-focused code review, automated scanning in CI/CD, and periodic penetration testing are all necessary layers of a mature security posture.

If you are building a web application in 2026, the question is not whether you will face these threats. It is whether you will be prepared when they arrive.

At Citadel, we help engineering teams build security into their applications from the ground up. Our security audit service provides a thorough assessment of your application against the OWASP Top 10 and beyond, with prioritized remediation guidance and hands-on support to implement fixes. Whether you need a one-time assessment or ongoing secure development partnership, get in touch to discuss how we can help harden your application.

Pregatit sa Incepi Proiectul?

Hai sa discutam cum te putem ajuta sa-ti aduci ideile la viata. Obtine o consultatie gratuita astazi.