The Essential Web Security Guide for Developers

Mohammad AmanMohammad Aman
26 min read

Web security isn't just a feature; it's the foundation of trust between your application and its users. As developers, understanding and implementing security measures is crucial to protect user data, prevent attacks, and maintain the integrity of your services. This guide covers essential web security concepts, common vulnerabilities, and practical ways to defend against them, with a focus on Node.js (using Express and Hono frameworks with TypeScript).

Why Web Security Matters

Imagine your website or app as a house. Web security is like installing locks, alarms, and strong doors to keep intruders out. Without it, sensitive information (like user passwords or credit card details) can be stolen, services can be disrupted, and your users' trust can be permanently damaged. For developers, building secure applications is a core responsibility.

1. Cross-Site Scripting (XSS)

  • What it is: XSS happens when an attacker manages to inject malicious scripts (usually JavaScript) into a webpage that other users view. The victim's browser runs this script, thinking it's legitimate, allowing the attacker to steal data (like login tokens stored in cookies), change page content, or redirect the user to harmful sites.

  • Types:

    • Reflected XSS: The malicious script comes from the current HTTP request (e.g., a link the user clicks). The server "reflects" the script back in the response, and the browser runs it.

    • Stored XSS: The malicious script is saved on the server (e.g., in a database comment or user profile) and served to any user who views that content later. This is often more dangerous as it affects multiple users.

  • Prevention:

    • a) Validate and Sanitize Input (Server-Side): Never trust data coming from the user (forms, URL parameters, etc.). Define what input is acceptable and reject or clean anything else.

      • Express (using express-validator with TypeScript):

          // Example: Validating a comment field with TypeScript
          import express, { Request, Response, NextFunction } from 'express';
          import { body, validationResult, Result, ValidationError } from 'express-validator';
        
          const app = express();
          app.use(express.json());
          app.use(express.urlencoded({ extended: true }));
        
          app.post(
            '/comment',
            // Validate: ensure it's not empty and escape harmful characters
            body('comment')
              .notEmpty().withMessage('Comment cannot be empty.')
              .trim()
              .escape(), // Converts <, >, &, ', " to HTML entities
        
            (req: Request, res: Response) => {
              // Find the validation errors in this request
              const errors: Result<ValidationError> = validationResult(req);
              if (!errors.isEmpty()) {
                // If there are errors, return 400 with the errors array
                return res.status(400).json({ errors: errors.array() });
              }
        
              // Input is considered safer now
              // req.body is typed as any by default with express.json(), cast or validate further if needed
              const safeComment: string = req.body.comment;
              console.log('Storing safe comment:', safeComment);
        
              // ... store safeComment in database ...
              res.send('Comment received!');
            }
          );
        
          // Example listener (uncomment and adjust port as needed)
          // const PORT = process.env.PORT || 3000;
          // app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
        
      • Hono (using Zod validator - already TypeScript): Hono often integrates with Zod or similar libraries for validation.

          // Example using Hono and Zod for validation (TypeScript)
          import { Hono } from 'hono';
          import { zValidator } from '@hono/zod-validator';
          import { z } from 'zod';
          import { html } from 'hono/html'; // For escaping
        
          const app = new Hono();
        
          // Define the schema for the form data
          const schema = z.object({
            comment: z.string().min(1, { message: 'Comment cannot be empty.' }),
          });
        
          app.post(
            '/comment',
            // Validate the form data against the schema
            zValidator('form', schema, (result, c) => {
              if (!result.success) {
                // If validation fails, return a 400 error
                return c.text('Invalid input!', 400);
              }
              // Note: Validation middleware typically handles the response on failure.
              // If you reach the main handler, validation has passed.
            }),
            // Main handler function, executed only if validation passes
            async (c) => {
              // Get the validated data (type-safe)
              const { comment } = c.req.valid('form');
        
              // Hono's html template tag escapes by default, making it safe for HTML context
              const safeCommentHtml = html`<p>${comment}</p>`;
              console.log('Storing safe comment (original):', comment);
        
              // ... store the original validated 'comment' string in the database ...
        
              // Example response
              return c.html(`<h1>Comment Received</h1>${safeCommentHtml}`);
            }
          );
        
          // export default app; // Standard export for Bun/Cloudflare Workers etc.
        
      • Sanitization Libraries: For allowing some HTML (like in a rich text editor), use libraries like DOMPurify (often used client-side before sending, but server-side validation is still essential) or server-side equivalents that strip out dangerous tags and attributes. Ensure types are handled correctly when integrating these.

    • b) Encode Output: When displaying user-provided data back on a page, encode special HTML characters (<, >, &, ", ') so the browser treats them as literal text, not HTML tags or script delimiters. Most modern template engines (EJS, Handlebars, Pug for Express; JSX/hono/html for Hono) do this automatically for variables by default. Be careful if you explicitly disable encoding or use methods like innerHTML.

      • EJS (Express): <%= userData %> escapes output. <%- userData %> does not escape (dangerous!). Ensure userData has the correct type passed from your TypeScript controller.

      • Hono (JSX/html): {userData} in JSX or ${userData} in hono/html escapes output. TypeScript ensures userData has the expected type.

    • c) Use Content Security Policy (CSP): This is a powerful HTTP header that tells the browser which sources are legitimate for loading resources (scripts, styles, images, etc.). It can drastically limit XSS impact.

      • Express (using helmet with TypeScript):

          import helmet from 'helmet';
          import express, { Request, Response } from 'express';
        
          const app = express();
        
          // Apply helmet middleware with CSP configuration
          app.use(
            helmet.contentSecurityPolicy({
              directives: {
                defaultSrc: ["'self'"], // Only allow resources from own origin by default
                scriptSrc: ["'self'", "trusted-cdn.com"], // Allow scripts from self and trusted CDN
                styleSrc: ["'self'", "'unsafe-inline'"], // Allow styles from self and inline styles (use nonces/hashes if possible instead of unsafe-inline)
                imgSrc: ["'self'", "data:"], // Allow images from self and data URIs
                objectSrc: ["'none'"], // Disallow plugins like Flash
                upgradeInsecureRequests: [], // Automatically upgrade http requests to https
                // Add other directives as needed
              },
            })
          );
        
          // Example route
          app.get('/', (req: Request, res: Response) => {
            res.send('Hello with CSP!');
          });
        
          // ... rest of your Express app ...
        
      • Hono (Manual Header - already TypeScript):

          import { Hono } from 'hono';
        
          const app = new Hono();
        
          // Middleware to add the CSP header to all responses
          app.use('*', async (c, next) => {
            // Wait for downstream middleware/handlers
            await next();
            // Set the CSP header on the outgoing response
            c.header(
              'Content-Security-Policy',
              "default-src 'self'; script-src 'self' trusted-cdn.com; style-src 'self'; object-src 'none'; upgrade-insecure-requests;"
            );
          });
        
          // Example route
          app.get('/', (c) => {
            return c.text('Hello from Hono with CSP!');
          });
        
          // ... rest of your Hono app ...
          // export default app;
        
    • d) Use HttpOnly Cookies: Set the HttpOnly flag on cookies (especially session cookies). This prevents client-side JavaScript from accessing them, meaning even if an attacker injects a script, they can't easily steal the session cookie.

      • Express (using express-session with TypeScript):

          import session from 'express-session';
          import express from 'express';
          // If using a session store like connect-redis, import its types too
        
          const app = express();
        
          // Make sure you have @types/express-session installed
          app.use(session({
            secret: 'your very secret key here', // Keep this secret and use env variable
            resave: false, // Don't save session if unmodified
            saveUninitialized: false, // Don't create session until something stored
            cookie: {
              secure: process.env.NODE_ENV === 'production', // Only send cookie over HTTPS in production
              httpOnly: true, // Prevent client-side JS access (essential for security)
              sameSite: 'Strict', // Or 'Lax' - Helps prevent CSRF attacks
              maxAge: 1000 * 60 * 60 * 24 // Example: 1 day expiry in milliseconds
            }
            // store: // Add a production-ready session store here (e.g., Redis, Mongo)
          }));
        
          // ... rest of your Express app ...
        
      • Hono (using hono/cookie - already TypeScript):

          import { Hono } from 'hono';
          import { setCookie, getCookie } from 'hono/cookie';
          import { sign, verify } from 'hono/jwt'; // Example for signing session data
        
          const app = new Hono();
          const SECRET = 'your-jwt-secret-key'; // Use environment variable
        
          app.get('/login', async (c) => {
            // --- Example Authentication Logic ---
            const userId = 'user123'; // Replace with actual user ID after verification
            const payload = {
               sub: userId,
               role: 'user',
               exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24) // 1 day expiry
            };
            const token = await sign(payload, SECRET);
            // --- End Example Auth ---
        
            // Set the session token in a secure cookie
            setCookie(c, 'session_token', token, {
              path: '/',
              secure: true, // Should be true in production (requires HTTPS)
              httpOnly: true, // Cannot be accessed by client-side JavaScript
              sameSite: 'Strict', // Protects against CSRF
              maxAge: 60 * 60 * 24 // Matches JWT expiry (in seconds for maxAge)
            });
            return c.text('Logged in successfully!');
          });
        
          // Middleware to verify session on protected routes
          app.use('/protected/*', async (c, next) => {
             const token = getCookie(c, 'session_token');
             if (!token) {
                 return c.text('Unauthorized', 401);
             }
             try {
                 const payload = await verify(token, SECRET);
                 c.set('user', payload); // Make user info available to handlers
                 await next();
             } catch (error) {
                 console.error("JWT verification failed:", error);
                 return c.text('Unauthorized', 401);
             }
          });
        
          app.get('/protected/data', (c) => {
              const user = c.get('user');
              return c.json({ message: "This is protected data", user });
          });
        
          // export default app;
        

2. Cross-Site Request Forgery (CSRF)

  • What it is: CSRF tricks a logged-in user's browser into making an unwanted request to a web application where they are authenticated. For example, an attacker could embed an image tag on a malicious site whose src points to a URL on your bank's site like https://yourbank.com/transfer?to=attacker&amount=1000. If you visit the malicious site while logged into your bank, your browser might automatically send your bank cookies along with the request, potentially executing the transfer without your knowledge.

  • Prevention:

    • a) Anti-CSRF Tokens (Synchronizer Token Pattern / Double Submit Cookie): These methods involve generating unique tokens to validate that requests originate from your application.

      • Synchronizer Token: Requires server-side session state to store and compare tokens.

      • Double Submit Cookie: Stores the token in a non-HttpOnly cookie and requires the client-side script to send the same token back in a header or form field. The server compares the cookie value and the submitted value.

      • Express (Conceptual TypeScript using Double Submit Cookie Pattern - csurf is unmaintained):

        ```typescript import express, { Request, Response, NextFunction } from 'express'; import cookieParser from 'cookie-parser'; import { randomBytes } from 'crypto'; // Use crypto for secure random generation

        const app = express(); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); // Needed to read the CSRF cookie

        const CSRF_COOKIE_NAME = '_csrfTokenCookie'; const CSRF_HEADER_NAME = 'x-csrf-token'; // Or use a form field name like _csrf

        // Middleware to generate and send CSRF cookie on GET requests (or initial page load) app.use((req: Request, res: Response, next: NextFunction) => { // Only set the cookie if it doesn't exist or on specific routes if needed if (req.method === 'GET' && !req.cookies[CSRF_COOKIE_NAME]) { // Generate a secure random token const csrfToken = randomBytes(32).toString('hex'); // Set the token in a cookie accessible by JavaScript on the client res.cookie(CSRF_COOKIE_NAME, csrfToken, { // httpOnly: false is REQUIRED for client-side script to read it httpOnly: false, secure: process.env.NODE_ENV === 'production', sameSite: 'Strict', // Important for security path: '/' // Set path appropriately }); // Optionally make token available for server-rendered forms if needed // res.locals.csrfTokenForForm = csrfToken; } next(); });

        // Middleware to validate CSRF token on state-changing requests (POST, PUT, DELETE) app.use((req: Request, res: Response, next: NextFunction) => { if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { const tokenFromCookie = req.cookies[CSRF_COOKIE_NAME]; // Get token from header (for AJAX) or form body (_csrf is common) const tokenFromRequest = req.header(CSRF_HEADER_NAME) || req.body._csrf;

        if (!tokenFromCookie || !tokenFromRequest || tokenFromCookie !== tokenFromRequest) { console.warn('Invalid CSRF token detected.'); return res.status(403).send('Invalid CSRF Token'); } console.log('CSRF token validated successfully.'); } next(); });

app.get('/form', (req: Request, res: Response) => { // Client-side JS needs to read the cookie _csrfTokenCookie // and include its value in the X-CSRF-Token header or _csrf form field // for subsequent POST/PUT/DELETE requests. res.send(`

Submit `); });

app.post('/process', (req: Request, res: Response) => { // If CSRF validation passed in middleware res.send('Data processed securely!'); });

// ... listener ...


        * **Hono (Manual Implementation Concept - already TypeScript):** Similar logic applies. You'd generate a token, store it (e.g., in a JWT session cookie or use Double Submit), pass it to the client, and validate it on state-changing requests using middleware.

            ```typescript
            // Conceptual Hono Double Submit Cookie Pattern
            import { Hono } from 'hono';
            import { getCookie, setCookie } from 'hono/cookie';
            import { Buffer } from 'buffer'; // Use Buffer for random bytes if needed in edge environments
            import { secureRandom } from 'hono/utils/secure-random'; // Use Hono's utility

            const app = new Hono();
            const CSRF_COOKIE_NAME = '_csrfTokenCookieHono';
            const CSRF_HEADER_NAME = 'x-csrf-token'; // Or form field _csrf

            // Middleware for CSRF protection
            app.use('*', async (c, next) => {
              const method = c.req.method;

              if (method === 'GET') {
                const existingToken = getCookie(c, CSRF_COOKIE_NAME);
                if (!existingToken) {
                  // Generate token using a secure method
                  const csrfToken = secureRandom(32); // Generate 32 random bytes as hex
                  setCookie(c, CSRF_COOKIE_NAME, csrfToken, {
                    httpOnly: false, // Required for client JS access
                    secure: true, // Use true in production (requires HTTPS)
                    sameSite: 'Strict',
                    path: '/'
                  });
                  c.set('csrfTokenForView', csrfToken); // Make available if needed for server-rendered forms
                } else {
                   c.set('csrfTokenForView', existingToken);
                }
              } else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
                const tokenFromCookie = getCookie(c, CSRF_COOKIE_NAME);
                // Prioritize header, then check form body
                let tokenFromRequest = c.req.header(CSRF_HEADER_NAME);
                if (!tokenFromRequest) {
                    try {
                        const body = await c.req.parseBody();
                        tokenFromRequest = body._csrf as string; // Assuming form field name is _csrf
                    } catch (e) {
                        // Ignore parsing errors if content type isn't form data
                    }
                }


                if (!tokenFromCookie || !tokenFromRequest || tokenFromCookie !== tokenFromRequest) {
                  console.warn('Hono: Invalid CSRF token detected.');
                  return c.text('Invalid CSRF Token', 403);
                }
                 console.log('Hono: CSRF token validated successfully.');
              }
              await next();
            });

            app.get('/form', (c) => {
              const token = c.get('csrfTokenForView'); // Get token if set by middleware
              // Client-side JS needs to read cookie and add token to request header or form field
              return c.html(`
                  <form id="honoForm" action="/process" method="POST">
                      <input type="text" name="data" value="some data">
                      <button type="submit">Submit</button>
                  </form>
                   <script>
                       // Similar client-side script as Express example needed here
                       // to read cookie '${CSRF_COOKIE_NAME}' and add hidden input or header.
                       function getCookie(name) { /* ... getCookie function ... */ }
                       const csrfToken = getCookie('${CSRF_COOKIE_NAME}');
                       const form = document.getElementById('honoForm');
                       if (csrfToken && form) {
                           const hiddenInput = document.createElement('input');
                           hiddenInput.type = 'hidden';
                           hiddenInput.name = '_csrf';
                           hiddenInput.value = csrfToken;
                           form.appendChild(hiddenInput);
                       }
                   </script>
              `);
            });

             app.post('/process', (c) => {
               // If validation passed
               return c.text('Hono: Data processed securely!');
             });

            // export default app;
  • b) SameSite Cookie Attribute: Instructs the browser when to send cookies with cross-site requests.

    • Strict: The browser will only send the cookie if the request originates from the same site the cookie belongs to. Prevents CSRF effectively but can break links from external sites if they rely on the session.

    • Lax: A balance. The cookie is sent for same-site requests and top-level navigations (e.g., clicking a link to your site from another site), but not for requests initiated by third-party sites (like images, iframes, forms submitted via JavaScript). This is often the default in modern browsers.

    • None: The cookie is sent with all requests (requires the Secure attribute). Use with caution.

    • Implementation: See the HttpOnly cookie examples above – the sameSite attribute is set there.

  • c) Check Origin or Referer Headers: As a secondary defense, you can check these headers on the server. The Origin header indicates the origin that initiated the request, and Referer indicates the previous page. You can verify if they match your site's domain. However, these headers can sometimes be missing or spoofed, so don't rely on them as your only defense.

    • Express (TypeScript): Access headers via req.headers.origin or req.headers.referer.

    • Hono (TypeScript): Access headers via c.req.header('origin') or c.req.header('referer').

3. SQL Injection (SQLi)

  • What it is: Attackers insert malicious SQL code into input fields (like login forms or search bars). If the application directly inserts this input into its database queries without proper handling, the malicious code can run, potentially allowing attackers to steal data, delete tables, bypass logins, or even gain control over the database server.

  • Prevention:

    • a) Prepared Statements (Parameterized Queries): This is the most effective defense. Instead of building SQL strings with user input directly, you use placeholders (? or $1, $2, etc., depending on the database/driver) in your SQL query. You then provide the user input separately as parameters. The database driver ensures the input is treated strictly as data, not as executable SQL code.

      • Node.js (using mysql2 library with TypeScript):

          // Ensure you have @types/node installed for basic types
          // mysql2 includes its own types
          import mysql, { RowDataPacket, Connection } from 'mysql2/promise';
        
          interface User extends RowDataPacket {
            id: number;
            username: string;
            // other user fields...
          }
        
          async function authenticateUser(username: string, passwordHash: string): Promise<User | null> {
            let connection: Connection | null = null;
            try {
              connection = await mysql.createConnection({
                host: 'localhost', // Replace with your config
                user: 'dbuser',
                password: 'dbpassword',
                database: 'appdb'
                // ... other connection details ...
              });
        
              // Use placeholders (?) for user input
              const sql = 'SELECT id, username FROM users WHERE username = ? AND password_hash = ?';
        
              // Provide user input as an array in the second argument
              // The <User[]> type assertion tells TypeScript what kind of rows to expect
              const [rows] = await connection.execute<User[]>(sql, [username, passwordHash]);
        
              if (rows.length > 0) {
                console.log('Authentication successful for user:', rows[0].username);
                return rows[0]; // Return the user data (type User)
              } else {
                console.log('Authentication failed for username:', username);
                return null;
              }
            } catch (err) {
              console.error('Database error during authentication:', err);
              // Consider more specific error handling/logging
              return null;
            } finally {
              // Ensure the connection is closed even if errors occur
              if (connection) {
                await connection.end();
              }
            }
          }
        
          // Example usage (password should already be hashed)
          // authenticateUser('testuser', 'hashed_password_from_db')
          //   .then(user => console.log('Logged in user:', user))
          //   .catch(error => console.error('Auth function error:', error));
        
      • Node.js (using pg library for PostgreSQL with TypeScript):

          // Ensure you have @types/pg installed
          import { Pool, PoolClient, QueryResult } from 'pg';
        
          // Define an interface for your user data structure
          interface User {
            id: number;
            username: string;
            // other fields...
          }
        
          const pool = new Pool({
            user: 'dbuser',
            host: 'localhost',
            database: 'appdb',
            password: 'dbpassword',
            port: 5432,
            // ... other pool config ...
          });
        
          async function authenticateUserPg(username: string, passwordHash: string): Promise<User | null> {
            let client: PoolClient | null = null;
            try {
              client = await pool.connect();
        
              // Use placeholders ($1, $2) for user input
              const sql = 'SELECT id, username FROM users WHERE username = $1 AND password_hash = $2';
        
              // Provide user input as an array in the second argument
              // Specify the expected row type in QueryResult<User>
              const result: QueryResult<User> = await client.query(sql, [username, passwordHash]);
        
              if (result.rows.length > 0) {
                console.log('Authentication successful for user:', result.rows[0].username);
                return result.rows[0]; // Return the first user found (type User)
              } else {
                console.log('Authentication failed for username:', username);
                return null;
              }
            } catch (err) {
              console.error('Database error during authentication:', err);
              return null;
            } finally {
              // Release the client back to the pool
              if (client) {
                client.release();
              }
            }
          }
        
          // Example usage:
          // authenticateUserPg('pguser', 'hashed_password_from_db')
          //  .then(user => console.log('Logged in user:', user));
        
    • b) Use ORMs (Object-Relational Mappers): Libraries like Sequelize, TypeORM, or Prisma often handle parameterization automatically when you use their query-building methods correctly. These libraries usually have excellent TypeScript support. However, be cautious if you use raw query functions within an ORM – ensure you're still using parameterization.

    • c) Least Privilege: Configure your database user accounts so the web application connects with the minimum permissions needed. For example, a user account that only reads data shouldn't have permission to delete tables.

    • d) Input Validation: While not a direct SQLi prevention method, validating input (e.g., ensuring an ID is actually a number) using libraries like express-validator or zod can prevent some types of injection attempts early on.

4. Weak Password Hashing

  • What it is: Storing passwords insecurely (e.g., as plain text or using outdated/weak hashing algorithms like MD5 or SHA1 without "salting"). If your database is breached, attackers can easily read or crack these passwords.

  • Why Hashing AND Salting?

    • Hashing: A one-way process that turns a password into a fixed-size string (the hash). It's computationally hard to reverse the hash back to the original password.

    • Salting: Adding a unique, random string (the salt) to each password before hashing it. The salt is stored alongside the hash. This ensures that even if two users have the same password, their stored hashes will be different. This defeats "rainbow table" attacks (precomputed tables of common password hashes).

  • Prevention:

    • a) Use Strong, Slow Hashing Algorithms: Modern, adaptive algorithms like bcrypt, Argon2 (often recommended), or PBKDF2 are designed to be slow, making brute-force attacks (trying many passwords) computationally expensive for attackers. Avoid fast algorithms like MD5 or SHA-1/SHA-256 for passwords.

    • b) Use a Unique Salt Per User: Never use a global salt or no salt at all. Generate a cryptographically secure random salt for each user when they register or change their password.

    • Node.js (using bcrypt library with TypeScript):

        // Ensure you have @types/bcrypt installed
        import bcrypt from 'bcrypt';
      
        // Cost factor determines how much time is needed to calculate a single hash.
        // Higher value means more secure but slower. Adjust based on server power.
        const saltRounds: number = 10; // 10-12 is a common range
      
        /**
         * Hashes a plain text password using bcrypt.
         * @param plainPassword The password to hash.
         * @returns A promise that resolves with the hashed password string.
         */
        async function hashPassword(plainPassword: string): Promise<string> {
          try {
            // bcrypt.hash automatically generates a unique salt for each password
            // and includes the salt and cost factor in the resulting hash string.
            const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
            console.log('Hashed Password generated:', hashedPassword);
            return hashedPassword; // Store this hash in the database
          } catch (err) {
            console.error('Error hashing password:', err);
            // Rethrow or handle the error appropriately in your application context
            throw new Error('Password hashing failed');
          }
        }
      
        /**
         * Compares a plain text password with a stored bcrypt hash.
         * @param plainPassword The password attempt from the user.
         * @param hashedPasswordFromDb The hash stored in the database.
         * @returns A promise that resolves with true if the password matches, false otherwise.
         */
        async function checkPassword(plainPassword: string, hashedPasswordFromDb: string): Promise<boolean> {
          try {
            // bcrypt.compare extracts the salt and cost factor from the stored hash
            // and hashes the plainPassword attempt using them, then compares the results.
            const match = await bcrypt.compare(plainPassword, hashedPasswordFromDb);
            console.log('Password comparison result:', match); // true or false
            return match;
          } catch (err) {
            console.error('Error comparing password:', err);
            // In case of error (e.g., invalid hash format), treat as non-match for security
            return false;
          }
        }
      
        // --- Example Usage ---
        async function runPasswordDemo() {
          const password = 'mysecretpassword123';
          try {
            const dbHash = await hashPassword(password);
            console.log(`Password "${password}" hashed to: ${dbHash}`);
      
            // Simulate checking login attempt
            const isLoginValid = await checkPassword(password, dbHash);
            console.log(`Login attempt with correct password valid? ${isLoginValid}`); // Should be true
      
            const isLoginInvalid = await checkPassword('wrongpassword', dbHash);
            console.log(`Login attempt with incorrect password valid? ${isLoginInvalid}`); // Should be false
      
          } catch (error) {
             console.error("Password demo failed:", error)
          }
        }
      
        // runPasswordDemo(); // Uncomment to run the example
      

5. Cross-Origin Resource Sharing (CORS)

  • What it is: Browsers enforce a security feature called the Same-Origin Policy (SOP). By default, a web page from one origin (e.g., https://myfrontend.com) is not allowed to make requests (like fetch or XMLHttpRequest) to a different origin (e.g., https://myapi.com). CORS is a mechanism that uses HTTP headers to tell browsers it's okay to relax the SOP for specific origins, allowing controlled cross-origin requests.

  • Why it's needed: Essential for modern web architectures where the frontend (React, Vue, Angular, etc.) is served from a different domain/port than the backend API. Without proper CORS configuration on the API server, the browser will block requests from the frontend.

  • Key Concepts & Headers:

    • Origin (Request Header): Sent by the browser, indicates the origin initiating the request.

    • Access-Control-Allow-Origin (Response Header): Sent by the server. Specifies which origin(s) are allowed to access the resource. Can be a specific origin (e.g., https://myfrontend.com) or * (allows any origin - use with caution, especially with credentials).

    • Access-Control-Allow-Methods (Response Header): Sent by the server (usually during a preflight request). Specifies which HTTP methods (GET, POST, PUT, DELETE, etc.) are allowed for cross-origin requests.

    • Access-Control-Allow-Headers (Response Header): Sent by the server (usually during a preflight request). Specifies which HTTP headers can be included in the actual cross-origin request.

    • Access-Control-Allow-Credentials (Response Header): Set to true if the server allows cookies or other credentials to be included in cross-origin requests. If set to true, Access-Control-Allow-Origin cannot be *.

    • Access-Control-Max-Age (Response Header): Specifies how long the results of a preflight request can be cached by the browser (in seconds).

    • Preflight Request (OPTIONS): For requests that might modify data (e.g., PUT, DELETE) or include custom headers or credentials, the browser first sends an OPTIONS request (a "preflight" check) to the server. The server responds with the allowed methods, headers, etc. If the preflight check passes, the browser sends the actual request.

  • Prevention/Configuration: Configure your backend API server to send the appropriate CORS headers.

    • Express (using cors middleware with TypeScript):

        // Ensure you have @types/cors installed
        import express from 'express';
        import cors, { CorsOptions } from 'cors';
      
        const app = express();
      
        // --- CORS Configuration ---
        // Define allowed origins
        const allowedOrigins = ['http://localhost:3000', 'https://myfrontend.com'];
      
        const corsOptions: CorsOptions = {
          origin: function (origin, callback) {
            // Allow requests with no origin (like mobile apps or curl requests)
            if (!origin) return callback(null, true);
            // Check if the origin is in the allowed list
            if (allowedOrigins.indexOf(origin) === -1) {
              const msg = 'The CORS policy for this site does not allow access from the specified Origin.';
              return callback(new Error(msg), false);
            }
            return callback(null, true);
          },
          methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Allowed methods
          allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], // Allowed headers
          credentials: true, // Allow cookies to be sent (requires specific origin, not '*')
          optionsSuccessStatus: 204 // Return 204 for preflight requests
        };
      
        // Enable CORS with the specified options
        app.use(cors(corsOptions));
      
        // Handle preflight requests explicitly for all routes (cors middleware often handles this)
        // app.options('*', cors(corsOptions)); // Redundant if cors() is used globally
      
        // --- Example API Route ---
        app.get('/api/data', (req, res) => {
          // If CORS check passed, this handler will execute
          res.json({ message: 'This data is CORS-enabled!' });
        });
      
        // ... rest of your Express app and listener ...
      
    • Hono (using @hono/cors middleware - already TypeScript):

        import { Hono } from 'hono';
        import { cors } from 'hono/cors'; // Import the CORS middleware
      
        const app = new Hono();
      
        // --- CORS Configuration ---
        // Apply CORS middleware to all routes or specific routes
        app.use('*', cors({
          origin: ['http://localhost:3000', 'https://myfrontend.com'], // Allowed origins
          allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Allowed methods
          allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], // Allowed headers
          credentials: true, // Allow cookies/credentials
          maxAge: 86400, // Cache preflight response for 1 day (in seconds)
        }));
      
        // --- Example API Route ---
        app.get('/api/data', (c) => {
          // If CORS check passed, this handler executes
          return c.json({ message: 'Hono data with CORS!' });
        });
      
        // ... rest of your Hono app ...
        // export default app;
      

6. Other Important Vulnerabilities & Best Practices

  • Broken Authentication & Session Management:

    • Use secure session management libraries (like express-session configured securely - see HttpOnly cookie example). Consider JWTs stored in secure HttpOnly cookies as an alternative for stateless sessions (as shown in the Hono cookie example).

    • Regenerate the session ID immediately after a user logs in (if using traditional sessions) to prevent session fixation attacks.

    • Implement proper session timeouts (both inactivity and absolute limits).

    • Provide a secure logout function that invalidates the session on the server (or invalidates the JWT token if possible, e.g., using a denylist).

    • Protect against brute-force login attempts using rate limiting.

  • Security Misconfiguration:

    • Don't run your Node.js process as root. Use a process manager like PM2 to run as a less privileged user.

    • Keep software (Node.js, database, OS, libraries) updated using tools like npm outdated or dependabot.

    • Use security middleware like helmet for Express to set various security headers easily (X-Content-Type-Options: nosniff, X-Frame-Options, Strict-Transport-Security, etc.). Set equivalent headers manually in Hono.

    • Configure error handling to avoid leaking sensitive information (stack traces) to the user in production. Log errors securely on the server. Use custom error handling middleware.

    • Disable directory listings on your web server (Nginx, Apache config).

    • Secure cloud service configurations (S3 bucket permissions, database access rules, API keys, etc.). Use Infrastructure as Code (IaC) tools for consistency.

  • Using Components with Known Vulnerabilities:

    • Regularly check your project dependencies for known security issues using npm audit --audit-level=high or bun audit. Integrate this into your CI/CD pipeline.

    • Update vulnerable dependencies promptly, testing carefully.

  • Server-Side Request Forgery (SSRF):

    • If your application makes requests to other URLs based on user input (e.g., fetching an image from a URL provided by the user), strictly validate that input.

    • Use allow-lists (whitelists) of permitted domains or IP addresses if possible, rather than trying to block bad ones (blacklisting).

    • Be careful about fetching resources from internal network addresses (e.g., 127.0.0.1, 169.254.x.x, private IP ranges). Use libraries designed to prevent SSRF if possible.

  • Rate Limiting:

    • Protect login endpoints, password reset forms, and computationally intensive APIs from abuse and brute-force attacks by limiting the number of requests a user (identified by IP, API key, or user ID) can make in a given time window.

    • Express (using express-rate-limit with TypeScript):

        import rateLimit from 'express-rate-limit';
        import express, { Request, Response, NextFunction } from 'express';
      
        const app = express();
      
        // Define the rate limiter options
        const loginLimiter = rateLimit({
          windowMs: 15 * 60 * 1000, // 15 minutes duration
          max: 10, // Max requests per window per IP
          message: { // Custom message (can be JSON)
             status: 429,
             message: 'Too many login attempts from this IP, please try again after 15 minutes'
          },
          standardHeaders: true, // Send standard `RateLimit-*` headers
          legacyHeaders: false, // Disable old `X-RateLimit-*` headers
          // keyGenerator: (req: Request, res: Response): string => { /* custom key logic */ return req.ip; },
          // handler: (req: Request, res: Response, next: NextFunction, options: Options) => { /* custom handler logic */ }
        });
      
        // Apply the rate limiter specifically to the login route
        app.post('/login', loginLimiter, (req: Request, res: Response) => {
          // --- Your actual login logic here ---
          // Example: Check credentials, etc.
          const { username, password } = req.body;
          if (username === 'admin' && password === 'password') { // Replace with secure check!
             res.send('Login successful (example)');
          } else {
             res.status(401).send('Invalid credentials');
          }
          // --- End login logic ---
        });
      
        // Other routes...
        app.get('/', (req, res) => res.send('Welcome!'));
      
        // ... listener ...
      
    • Hono: Hono has community middleware for rate limiting (@hono/rate-limiter) or you can implement custom middleware using a store like Redis.

Security Headers Summary (using helmet for Express with TypeScript)

helmet is a great middleware for Express that sets many important security headers with sensible defaults.

import helmet from 'helmet';
import express from 'express';

const app = express();

// Use helmet with default settings (recommended starting point)
// Helmet's types are generally included or available via @types/helmet
app.use(helmet());

/* Helmet sets headers like:
 - Content-Security-Policy: Sensible defaults (can be customized as shown earlier)
 - Strict-Transport-Security: Enforces HTTPS (HSTS)
 - X-Content-Type-Options: nosniff (Prevents MIME type sniffing)
 - X-DNS-Prefetch-Control: off (Controls browser DNS prefetching)
 - X-Download-Options: noopen (Prevents IE users from opening downloads directly)
 - X-Frame-Options: SAMEORIGIN (Prevents clickjacking)
 - X-Permitted-Cross-Domain-Policies: none (Restricts Adobe Flash/PDF policies)
 - X-XSS-Protection: 0 (Disables deprecated browser XSS filter, relies on CSP)
 - Referrer-Policy: strict-origin-when-cross-origin (Modern default, controls referrer info)
 - Cross-Origin-Embedder-Policy: require-corp
 - Cross-Origin-Opener-Policy: same-origin
 - Cross-Origin-Resource-Policy: same-origin
 (Note: Default values might change between helmet versions)
*/

// You can customize individual headers if needed, e.g.:
app.use(helmet.frameguard({ action: 'deny' })); // Completely deny framing
app.use(helmet.hsts({ maxAge: 60 * 60 * 24 * 365, includeSubDomains: true, preload: true })); // Customize HSTS

// ... rest of your app ...

For Hono, you would typically set these headers manually using c.header() in middleware, similar to the CSP example earlier, or use specific middleware like @hono/cors, ensuring you understand the purpose and correct values for each header.

Conclusion: Security is an Ongoing Process

Web security is not a one-time task. New vulnerabilities are discovered constantly, and attackers refine their techniques. As a developer using TypeScript and Node.js, you need to:

  • Stay Informed: Keep up with security news and best practices (OWASP Top 10, Node Security Platform - nsp successor npm audit).

  • Think Securely: Consider security implications during design and coding. Leverage TypeScript's type safety but don't rely on it alone for security.

  • Validate Everything: Never trust external input. Use robust validation libraries like Zod or express-validator.

  • Use Secure Defaults: Leverage framework (Express/Hono) and library (Helmet, bcrypt) security features. Configure them correctly.

  • Keep Updated: Patch your systems and dependencies regularly (npm update, bun update). Use tools like Dependabot.

  • Test: Use security scanning tools (SAST, DAST), linters with security rules, and consider penetration testing for critical applications. Write tests for your security controls.

By integrating these practices into your development workflow, you can build applications that are significantly more resilient to attack, protecting both your users and your reputation.

0
Subscribe to my newsletter

Read articles from Mohammad Aman directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mohammad Aman
Mohammad Aman

Full-stack developer with a good foundation in frontend, now specializing in backend development. Passionate about building efficient, scalable systems and continuously sharpening my problem-solving skills. Always learning, always evolving.