Back to blog

Security

Web Application Security: Beyond the OWASP Top 10

11 min readEnviaIT Engineering

Every web application we build handles sensitive data — user credentials, payment information, personal details, business logic. Security isn't something we bolt on before launch. It's woven into our development process from the first commit.

This article covers the security practices we apply to every project, organized from the most common vulnerabilities to the operational practices that keep production systems safe. If you're building a web application in 2025, consider this a practical checklist.

The OWASP Top 10: a starting point, not a finish line

The OWASP Top 10 is the industry-standard list of the most critical web application security risks. We treat it as the floor, not the ceiling. Every developer on our team knows these by heart:

  1. Broken Access Control — Users accessing data or functions they shouldn't
  2. Cryptographic Failures — Sensitive data exposed due to weak or missing encryption
  3. Injection — SQL, NoSQL, OS command, and LDAP injection
  4. Insecure Design — Flawed architecture that can't be fixed with code
  5. Security Misconfiguration — Default credentials, open cloud storage, verbose errors
  6. Vulnerable Components — Using libraries with known vulnerabilities
  7. Authentication Failures — Broken login, session management, credential stuffing
  8. Data Integrity Failures — Untrusted deserialization, compromised CI/CD pipelines
  9. Logging and Monitoring Failures — Not detecting or responding to breaches
  10. Server-Side Request Forgery (SSRF) — Tricking the server into making requests to internal resources

Let's go deeper on the ones that matter most in modern web applications.

Authentication: getting the foundation right

Authentication is where most security breaches begin. A weak login system compromises everything behind it.

Password handling

We never store passwords in plain text. We never store passwords with MD5 or SHA-256. We use bcrypt with a cost factor of 12 as our minimum:

import bcrypt from "bcrypt";

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

For new projects, we're evaluating Argon2id as an upgrade. It's memory-hard (resistant to GPU attacks) and is the winner of the Password Hashing Competition. The bcrypt-to-Argon2 migration path is straightforward: rehash on next successful login.

Multi-factor authentication

Every application with sensitive data gets MFA. We implement TOTP (Time-based One-Time Password) using standard libraries:

import { authenticator } from "otplib";

// Generate secret for user
const secret = authenticator.generateSecret();

// Verify token during login
const isValid = authenticator.verify({
  token: userProvidedCode,
  secret: user.mfaSecret,
});

We also support WebAuthn/passkeys for applications where the user experience matters as much as security. Passkeys eliminate phishing entirely — there's no password to steal.

Session management

Our session rules:

  • Session tokens are cryptographically random, 256-bit minimum
  • HttpOnly, Secure, SameSite=Strict flags on all session cookies
  • Session expiration: 24 hours for general applications, 1 hour for financial/healthcare
  • Absolute timeout: Force re-authentication after 7 days regardless of activity
  • Session invalidation on password change — all sessions, not just the current one
// Secure cookie configuration
const sessionCookie = {
  name: "__session",
  httpOnly: true,
  secure: true,           // HTTPS only
  sameSite: "strict",     // No cross-site sending
  maxAge: 24 * 60 * 60,   // 24 hours
  path: "/",
  domain: ".example.com",
};

Authorization: the access control layer

Authentication tells you who the user is. Authorization tells you what they can do. We've seen applications where authentication is solid but authorization is an afterthought — every user can access every endpoint.

Role-based access control (RBAC)

For most applications, RBAC is sufficient. We define roles and permissions explicitly:

const permissions = {
  admin: [
    "users:read",
    "users:write",
    "users:delete",
    "orders:read",
    "orders:write",
    "orders:delete",
    "settings:manage",
  ],
  manager: [
    "users:read",
    "orders:read",
    "orders:write",
    "reports:read",
  ],
  user: [
    "orders:read",
    "orders:write", // own orders only
    "profile:manage",
  ],
} as const;

// Middleware
function requirePermission(permission: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const userPermissions = permissions[req.user.role];
    if (!userPermissions?.includes(permission)) {
      return res.status(403).json({
        error: "FORBIDDEN",
        message: "You do not have permission to perform this action",
      });
    }
    next();
  };
}

Critical point: always check authorization on the server side. Client-side route guards are a UX convenience, not a security measure. A determined attacker will bypass any client-side check.

Object-level authorization

RBAC alone isn't enough. You also need to verify that a user has access to the specific resource they're requesting. User A shouldn't be able to view User B's orders just by changing the ID in the URL:

// Bad: only checks if user has 'orders:read' permission
app.get("/api/orders/:id", requirePermission("orders:read"), async (req, res) => {
  const order = await db.order.findUnique({ where: { id: req.params.id } });
  return res.json(order);
});

// Good: also checks resource ownership
app.get("/api/orders/:id", requirePermission("orders:read"), async (req, res) => {
  const order = await db.order.findUnique({ where: { id: req.params.id } });

  if (!order) {
    return res.status(404).json({ error: "NOT_FOUND" });
  }

  if (order.userId !== req.user.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "FORBIDDEN" });
  }

  return res.json(order);
});

This is Broken Access Control — the #1 item on the OWASP Top 10 — and it's still the most common vulnerability we find in code reviews.

Injection prevention

SQL injection is a solved problem in theory, but it still appears in production code. The rule is absolute: never concatenate user input into queries.

SQL injection

// VULNERABLE: string concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;

// SAFE: parameterized query
const user = await db.query("SELECT * FROM users WHERE email = $1", [email]);

// SAFE: ORM with parameterized queries (Prisma)
const user = await prisma.user.findUnique({ where: { email } });

ORMs like Prisma and Drizzle handle parameterization automatically for standard operations. But be careful with raw queries — every ORM has an escape hatch for raw SQL, and those escape hatches bypass the protection.

NoSQL injection

MongoDB and other NoSQL databases have their own injection vectors:

// VULNERABLE: user input directly in query operator
const user = await db.collection("users").findOne({
  email: req.body.email,
  password: req.body.password, // Attacker sends { "$gt": "" }
});

// SAFE: validate input types before querying
import { z } from "zod";

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128),
});

const { email, password } = loginSchema.parse(req.body);

Input validation as a first-class concern

We validate every piece of external input at the application boundary using Zod:

import { z } from "zod";

const createUserSchema = z.object({
  name: z.string().min(1).max(100).trim(),
  email: z.string().email().max(254).toLowerCase(),
  password: z.string().min(12).max(128),
  role: z.enum(["user", "manager"]), // No one can self-assign admin
  bio: z.string().max(500).optional(),
});

// In the route handler
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({
    error: "VALIDATION_ERROR",
    details: result.error.flatten(),
  });
}
// result.data is now typed and sanitized

Cross-Site Scripting (XSS)

XSS allows attackers to inject malicious scripts into your pages. Modern frameworks like React and Next.js escape output by default, but there are still common pitfalls:

// DANGEROUS: dangerouslySetInnerHTML with user content
<div dangerouslySetInnerHTML={{ __html: userComment }} />

// SAFE: let React escape the content
<div>{userComment}</div>

// If you MUST render HTML (e.g., from a rich text editor):
import DOMPurify from "dompurify";

const sanitized = DOMPurify.sanitize(userComment, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "li"],
  ALLOWED_ATTR: ["href", "target"],
});

<div dangerouslySetInnerHTML={{ __html: sanitized }} />

Content Security Policy

A well-configured CSP header is your second line of defense against XSS. It tells the browser which sources of content are trusted:

// Security headers middleware
const securityHeaders = {
  "Content-Security-Policy": [
    "default-src 'self'",
    "script-src 'self' 'nonce-{RANDOM}'",
    "style-src 'self' 'unsafe-inline'",     // Required for many CSS-in-JS
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.example.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
  ].join("; "),
  "X-Content-Type-Options": "nosniff",
  "X-Frame-Options": "DENY",
  "X-XSS-Protection": "0",                  // Deprecated, use CSP instead
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
  "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
};

Note on X-XSS-Protection: 0: This header is intentionally disabled. The browser's built-in XSS filter has been deprecated because it introduced its own vulnerabilities. CSP is the correct replacement.

Cross-Site Request Forgery (CSRF)

CSRF tricks a user's browser into making unwanted requests to your application using their existing session. If your app uses cookies for authentication (most web apps do), you need CSRF protection.

Our approach:

  • SameSite=Strict cookies block most CSRF attacks in modern browsers
  • CSRF tokens for form submissions as a defense-in-depth measure
  • Check the Origin header on all state-changing requests
// CSRF protection middleware
function csrfProtection(req: Request, res: Response, next: NextFunction) {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) {
    return next(); // Safe methods don't need CSRF protection
  }

  const origin = req.headers.get("origin");
  const allowedOrigins = ["https://example.com", "https://www.example.com"];

  if (!origin || !allowedOrigins.includes(origin)) {
    return res.status(403).json({ error: "CSRF_VALIDATION_FAILED" });
  }

  next();
}

Dependency security

Your application is only as secure as its weakest dependency. A single compromised npm package can expose your entire system.

Automated scanning

We run dependency audits in every CI pipeline:

# In our GitHub Actions workflow
- name: Security audit
  run: |
    npm audit --audit-level=high
    npx better-npm-audit audit --level high

- name: Check for known vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    scan-type: "fs"
    scan-ref: "."
    severity: "HIGH,CRITICAL"
    exit-code: "1"

Dependabot or Renovate runs on every repository, automatically creating PRs for security updates. Critical vulnerabilities are treated as P0 issues — they block all other work until resolved.

Lockfile integrity

We always commit lockfiles (package-lock.json or pnpm-lock.yaml) and use npm ci (not npm install) in CI. This ensures builds use the exact dependency versions that were tested, not whatever happens to be latest.

Secrets management

Secrets — API keys, database credentials, encryption keys — are never stored in code, environment files committed to git, or container images.

Our hierarchy of secrets management:

  1. AWS Secrets Manager for production credentials (database URLs, API keys)
  2. Environment variables injected at runtime by the deployment platform
  3. .env.local for local development only, always in .gitignore
  4. 1Password for team-shared secrets (third-party service credentials)
# .gitignore — non-negotiable entries
.env
.env.local
.env.production
*.pem
*.key
credentials.json

We also run git-secrets as a pre-commit hook to prevent accidental commits of AWS keys, private keys, or other sensitive patterns:

# Install git-secrets hooks
git secrets --install
git secrets --register-aws
git secrets --add 'PRIVATE.KEY'
git secrets --add 'password\s*=\s*.+'

Encryption

In transit

Every application uses HTTPS. No exceptions. We enforce this with:

  • HSTS headers with a minimum max-age of 2 years
  • TLS 1.2 minimum, TLS 1.3 preferred
  • Certificate management through AWS Certificate Manager (automatic renewal)

At rest

Sensitive data in databases is encrypted at the storage level (RDS encryption) and at the application level for particularly sensitive fields:

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

const ALGORITHM = "aes-256-gcm";
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, "hex"); // 32 bytes

export function encrypt(plaintext: string): string {
  const iv = randomBytes(16);
  const cipher = createCipheriv(ALGORITHM, KEY, iv);

  let encrypted = cipher.update(plaintext, "utf8", "hex");
  encrypted += cipher.final("hex");

  const authTag = cipher.getAuthTag().toString("hex");

  return `${iv.toString("hex")}:${authTag}:${encrypted}`;
}

export function decrypt(ciphertext: string): string {
  const [ivHex, authTagHex, encrypted] = ciphertext.split(":");
  const iv = Buffer.from(ivHex, "hex");
  const authTag = Buffer.from(authTagHex, "hex");

  const decipher = createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encrypted, "hex", "utf8");
  decrypted += decipher.final("utf8");

  return decrypted;
}

We use AES-256-GCM because it provides both confidentiality and integrity (authenticated encryption). Never use ECB mode. Never reuse initialization vectors.

Our security checklist

Every project goes through this checklist before launch. No exceptions:

Authentication and sessions:

  • [ ] Passwords hashed with bcrypt (cost 12+) or Argon2id
  • [ ] MFA available for sensitive accounts
  • [ ] Session cookies: HttpOnly, Secure, SameSite=Strict
  • [ ] Account lockout after 5 failed login attempts
  • [ ] Password reset tokens are single-use and expire in 1 hour

Authorization:

  • [ ] Server-side permission checks on every endpoint
  • [ ] Object-level authorization (users can only access their own data)
  • [ ] Admin functions require re-authentication

Input and output:

  • [ ] All user input validated with schema validation (Zod)
  • [ ] Parameterized queries for all database operations
  • [ ] Output encoding to prevent XSS
  • [ ] File uploads validated by type, size, and content

HTTP security:

  • [ ] HTTPS enforced with HSTS
  • [ ] CSP header configured
  • [ ] CSRF protection on state-changing requests
  • [ ] CORS restricted to known origins
  • [ ] Security headers set (X-Content-Type-Options, X-Frame-Options, etc.)

Infrastructure:

  • [ ] Secrets stored in Secrets Manager, not in code
  • [ ] Dependencies audited for known vulnerabilities
  • [ ] Database encrypted at rest
  • [ ] Logging captures security events (failed logins, permission denials)
  • [ ] Error messages don't leak internal details to users

Operations:

  • [ ] Dependency updates automated (Dependabot/Renovate)
  • [ ] Pre-commit hooks prevent secret commits
  • [ ] Penetration testing scheduled before major launches
  • [ ] Incident response plan documented

Security is a process, not a product

The tools and techniques in this article are a starting point. Security is an ongoing practice that evolves with the threat landscape. We review and update our security practices quarterly, run tabletop exercises for incident response, and stay current with emerging attack vectors.

The most important thing isn't having the perfect security setup on day one. It's having a culture where security is everyone's responsibility — developers, designers, product managers — and where reporting a potential vulnerability is celebrated, not punished.


Want to audit your application's security or build with security in mind from the start? Let's talk about making your product resilient against modern threats.