DEV Community

Cover image for 7 Essential Security Patterns Every Web Developer Must Know for Building Bulletproof Applications
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

7 Essential Security Patterns Every Web Developer Must Know for Building Bulletproof Applications

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Building security into a web application can feel overwhelming. I’ve been there. You start with a simple login form, and suddenly you’re reading about cryptographic nonces and SameSite cookie policies. The truth is, modern security isn't about building a single, impenetrable wall. It's about constructing several reliable, interconnected layers of defense. If one layer has a weakness, the others are there to help. Let me walk you through seven practical patterns that form this layered defense, translating complex ideas into actionable steps.

Let's start with how users prove who they are. For years, a username and password was the standard, but we now know it's a fragile one. Passwords get reused, stolen, or guessed. A better approach is to add more factors or remove the password entirely. Think of it like your house: a lock (password) is okay, but a lock plus an alarm system (a second factor) is much stronger.

One of the most exciting developments is passwordless authentication. Instead of typing a secret, you use something you have, like a security key or your phone, to prove your identity. The technology behind this is called WebAuthn. Here’s a simplified look at how it works.

First, when a user registers, your website asks their browser to create a unique cryptographic key pair for your site. The private key stays securely on their device; you only store the public key. Later, when they want to sign in, your site sends a challenge. Their device signs this challenge with the private key, and your server verifies it with the public key. No password travels over the network.

// Example: Registering a user's device (like a security key)
async function registerNewDevice(userId, userName) {
  // We prepare a request for the user's browser/security key
  const creationOptions = {
    challenge: generateRandomChallenge(), // A one-time random string
    rp: { name: 'Our Secure App' },       // Relying Party (that's us)
    user: {
      id: encodeForBrowser(userId),       // A user handle, not the username
      name: userName,
      displayName: 'User Display Name'
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7 }     // We accept ES256 algorithm keys
    ]
  };

  // This triggers the browser's dialog for a security key or fingerprint
  const newCredential = await navigator.credentials.create({
    publicKey: creationOptions
  });

  // We send the public key data to our server for storage
  await sendToServer('/register-credential', {
    userId: userId,
    credential: newCredential
  });
}
Enter fullscreen mode Exit fullscreen mode

The sign-in process is similar but in reverse. You ask for the credential tied to the user, their device signs a new challenge, and you verify the signature. This method resists phishing because the credential is bound to our website's origin. Even if a user is tricked, the credential won't work on the fake site.

Once we know who a user is, we need to control what they can do. This is authorization. A common simple method is Role-Based Access Control (RBAC), where you check if a user has an "admin" or "user" role. This is good, but can be inflexible. A more detailed approach is Attribute-Based Access Control (ABAC). Here, you make decisions based on multiple attributes: who the user is, what resource they want, what action they're taking, and even the context, like the time of day.

Imagine a document editing system. An RBAC system might let all "editors" edit any document. An ABAC system could allow editing only if the user is an editor and the document belongs to their department and it's a weekday. This is more powerful and secure.

Here’s a conceptual middleware for ABAC. You define policies as sets of rules, and the middleware checks the request against them.

// A policy definition for our document system
const documentAccessPolicy = [
  {
    // This rule applies to the /documents/:id path
    resourcePattern: '/documents/:id',
    actions: ['GET', 'PUT'],
    // The conditions that must ALL be true
    conditions: [
      {
        attribute: 'user.role',   // We look at the user's role
        operator: 'includes',     // Check if the role is in a list
        value: ['editor', 'admin']
      },
      {
        attribute: 'document.ownerDepartment',
        operator: 'equals',
        value: 'user.department'  // User can only access docs in their dept
      },
      {
        attribute: 'environment.time',
        operator: 'isWeekday'     // Custom logic for context
      }
    ]
  }
];

// A simplified middleware function
function checkAccess(policies, request) {
  const user = request.user;
  const resource = request.path; // e.g., '/documents/123'
  const action = request.method; // e.g., 'PUT'

  for (const policy of policies) {
    // Check if this policy applies to this resource/action
    if (matchesPattern(resource, policy.resourcePattern) && policy.actions.includes(action)) {
      // Evaluate all conditions for this policy
      const allConditionsMet = policy.conditions.every(condition => {
        return evaluateCondition(condition, user, resource, request);
      });
      if (allConditionsMet) {
        return true; // Access granted
      }
    }
  }
  return false; // No policy allowed this access
}
Enter fullscreen mode Exit fullscreen mode

This pattern moves complex "if-else" logic out of your route handlers and into a central, declarative policy file that’s easier to audit and manage.

Data is a primary target. We must protect it when stored (at rest) and when sent between the client and server (in transit). For data in transit, always use HTTPS (TLS). It’s non-negotiable. For data at rest, use encryption.

A key principle is to encrypt data as close to the source as possible. Sometimes, this means encrypting sensitive data on the client side before it ever reaches your server. This ensures that even if your database is compromised, the data is still protected. However, you must manage the encryption keys very carefully.

Let’s consider encrypting a user's private notes on the client side.

// Client-side: Encrypting a note before sending to the server
async function encryptNote(noteText, secretKey) {
  // Convert the text to a format we can encrypt
  const textData = new TextEncoder().encode(noteText);

  // Generate a random Initialization Vector (IV) - crucial for security
  const iv = window.crypto.getRandomValues(new Uint8Array(12));

  // Perform the encryption using AES-GCM, a strong algorithm
  const encryptedContent = await window.crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv: iv
    },
    secretKey, // The CryptoKey object derived from a user password
    textData
  );

  // We need to store both the IV and the encrypted data.
  // The IV is not secret, but it must be unique for each encryption.
  const packedData = {
    iv: arrayBufferToBase64(iv),
    encryptedData: arrayBufferToBase64(encryptedContent),
    algorithm: 'AES-GCM'
  };

  // Send this packedData object to your server for storage
  return saveToServer('/api/notes', packedData);
}
Enter fullscreen mode Exit fullscreen mode

On the server, you would store this packedData blob in the database. To read it, the client must fetch it and decrypt it locally with the correct key. Your server never sees the unencrypted note or the user's secret key. The hard part is managing those user keys securely, often involving techniques like wrapping them with a key derived from the user's password.

Every piece of data that comes from outside your application—a form field, an API call, a file upload—is a potential threat. The goal of input validation and sanitization is to ensure data is what you expect and to neutralize any dangerous parts.

Validation is about structure: "Is this a valid email address?" "Is this number within an expected range?" Sanitization is about safety: "Remove any <script> tags from this HTML." You must do both, and you must do it on the server. Client-side validation is for user experience only; it can be bypassed in seconds.

I prefer using a library with a schema-based approach. You define the exact shape and rules for your data, and the library does the heavy lifting.

// Using a validation library (conceptual example)
const userInputSchema = {
  username: {
    type: 'string',
    minLength: 3,
    maxLength: 30,
    pattern: /^[a-zA-Z0-9_]+$/ // Only alphanumeric and underscore
  },
  email: {
    type: 'string',
    format: 'email'
  },
  age: {
    type: 'integer',
    optional: true,
    min: 13
  }
};

function validateSignup(requestBody) {
  const errors = [];

  // Validate username
  if (typeof requestBody.username !== 'string') {
    errors.push('Username must be text.');
  } else if (!/^[a-zA-Z0-9_]+$/.test(requestBody.username)) {
    errors.push('Username can only contain letters, numbers, and _.');
  }

  // More validation for email, age, etc...
  // This is tedious and error-prone to write by hand.

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

Using a dedicated library is safer and cleaner. For sanitization, especially with HTML, never use simple regex. Use a robust library designed to handle the countless obscure ways to write malicious HTML.

// Simple HTML Sanitization (using a library's concept)
function getSafeUserComment(rawHtmlInput) {
  // A good library knows all the tricky XSS vectors
  const cleanHtml = sanitizeLibrary.clean(rawHtmlInput, {
    allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
    allowedAttributes: {
      'a': ['href', 'title'] // We only allow 'href' and 'title' on <a> tags
    },
    // Disallow all CSS and JavaScript event handlers
    allowedSchemes: ['http', 'https', 'mailto']
  });

  return cleanHtml; // Now safe to insert into your page
}
Enter fullscreen mode Exit fullscreen mode

A session is how your application remembers an authenticated user between page requests. Historically, this was done with session IDs stored in cookies. Modern applications often use tokens, like JSON Web Tokens (JWTs). The critical pattern here is to keep session lifetimes short and have a secure way to renew them.

A good practice is to use a short-lived access token (e.g., valid for 15-60 minutes) and a long-lived refresh token. The access token is sent with every API request. When it expires, the client uses the refresh token to get a new one. This limits the damage if an access token is stolen. The server must be able to revoke refresh tokens.

// Simplified Token Refresh Flow
async function refreshAccessToken(refreshToken) {
  // 1. Send the refresh token to a dedicated, secure endpoint
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refreshToken: refreshToken })
  });

  if (!response.ok) {
    // If refresh fails, the user must log in again
    throw new Error('Session expired');
  }

  const result = await response.json();

  // 2. Get back a new access token (and often a new refresh token)
  const newAccessToken = result.accessToken;
  const newRefreshToken = result.refreshToken;

  // 3. Securely store the new tokens
  secureTokenStore.set('access', newAccessToken);
  secureTokenStore.set('refresh', newRefreshToken);

  // 4. Retry the original request with the new access token
  return newAccessToken;
}

// Wrapper for API calls that handles token refresh automatically
async function makeSecureApiCall(url, options) {
  let accessToken = secureTokenStore.get('access');

  // Try the request with the current token
  options.headers = { ...options.headers, Authorization: `Bearer ${accessToken}` };
  let response = await fetch(url, options);

  // If token is expired (HTTP 401), try to refresh
  if (response.status === 401) {
    try {
      accessToken = await refreshAccessToken(secureTokenStore.get('refresh'));
      // Update header and retry the request
      options.headers.Authorization = `Bearer ${accessToken}`;
      response = await fetch(url, options);
    } catch (refreshError) {
      // Refresh failed, redirect to login page
      redirectToLogin();
      return;
    }
  }

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Your web browser can be a powerful ally in security if you instruct it properly. You do this through HTTP response headers. These headers tell the browser to enforce certain security policies. Setting them is one of the highest-impact, lowest-effort security steps you can take.

Let's configure some essential headers in a Node.js/Express application.

const express = require('express');
const app = express();

// Middleware to set security headers
app.use((req, res, next) => {
  // 1. Stop browsers from guessing content type (can prevent some attacks)
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // 2. Instruct browser to stop page loading if XSS is detected
  res.setHeader('X-XSS-Protection', '1; mode=block');

  // 3. Prevent this site from being embedded in an <iframe> (clickjacking defense)
  res.setHeader('X-Frame-Options', 'DENY');

  // 4. Content Security Policy (CSP) - This is a very powerful header.
  // It defines which sources of scripts, styles, images, etc., are allowed.
  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",               // By default, only load from our domain
      "script-src 'self' 'unsafe-inline'", // Allow scripts from self and inline (simplified for example)
      "style-src 'self' fonts.googleapis.com", // Allow our styles and Google Fonts
      "img-src 'self' data: https:",      // Allow images from self, data URLs, and HTTPS sites
      "font-src 'self' fonts.gstatic.com",
      "connect-src 'self' api.example.com" // Allow fetch/XHR to our domain and our API
    ].join('; ')
  );

  // 5. Control what referrer information is sent when leaving your site
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

  next();
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

CSP is the most complex but most valuable header. It can stop entire classes of attacks, like Cross-Site Scripting (XSS), by whitelisting trusted sources of content. Start with a strict policy and loosen it as needed, logging any violations the browser reports.

Your application is built on a mountain of other people's code: libraries, frameworks, and tools. A vulnerability in any one of them is a vulnerability in your app. This pattern is about continuously managing that risk.

You need to: 1) Know what you're using, 2) Know what's vulnerable, and 3) Update safely. Automate this.

// package.json with scripts for security checks
{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "audit": "npm audit",                // Uses built-in npm audit
    "audit:fix": "npm audit fix --force", // Try to fix automatically
    "security-scan": "npx snyk test"     // Use a more detailed scanner like Snyk
  },
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "4.17.21"                  // Pin to a specific, known-good version
  }
}
Enter fullscreen mode Exit fullscreen mode

Integrate these checks into your development process. Run npm audit in your CI/CD pipeline and fail the build if critical vulnerabilities are found. Use tools like Dependabot (GitHub) or RenovateBot to automatically create pull requests that update your dependencies when security fixes are released.

# Example GitHub Actions workflow to check for vulnerabilities on every push
name: Security Scan

on: [push]

jobs:
  security-audit:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci  # Clean install for consistency

      - name: Run NPM Audit
        run: npm audit --audit-level=high
        # If this command finds a "high" or "critical" vulnerability, it will fail.

      - name: Run Snyk Scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        continue-on-error: false # Fail the build if Snyk finds issues
Enter fullscreen mode Exit fullscreen mode

View these automated pull requests not as a nuisance, but as a free, automated security team working for you. Review them regularly and merge them quickly.

I find that security becomes less daunting when you see it as a set of concrete, habitual practices rather than a mysterious art. Start with the easy wins: enforce HTTPS, set your security headers, and audit your dependencies. Then, layer on stronger authentication, precise authorization, and rigorous input validation. Finally, protect the data itself with encryption and manage sessions carefully.

The goal isn't perfect, unbreakable security—that doesn't exist. The goal is to make it so difficult and expensive for an attacker that they move on to an easier target. By weaving these seven patterns into the fabric of your application from the start, you build that resilient, layered defense. You create an application that is not only functional but fundamentally trustworthy.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)