The Essential Web Security Guide for Developers


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 likeinnerHTML
.EJS (Express):
<%= userData %>
escapes output.<%- userData %>
does not escape (dangerous!). EnsureuserData
has the correct type passed from your TypeScript controller.Hono (JSX/html):
{userData}
in JSX or${userData}
inhono/html
escapes output. TypeScript ensuresuserData
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 likehttps://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(`
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 theSecure
attribute). Use with caution.Implementation: See the
HttpOnly
cookie examples above – thesameSite
attribute is set there.
c) Check
Origin
orReferer
Headers: As a secondary defense, you can check these headers on the server. TheOrigin
header indicates the origin that initiated the request, andReferer
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
orreq.headers.referer
.Hono (TypeScript): Access headers via
c.req.header('origin')
orc.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
orzod
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 (likefetch
orXMLHttpRequest
) 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 totrue
if the server allows cookies or other credentials to be included in cross-origin requests. If set totrue
,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 anOPTIONS
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
orbun 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
successornpm 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.
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.