rvices. We use a functional middleware approach that fails fast and returns structured error responses.
Implementation:
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['created_at', 'updated_at', 'id']).default('created_at'),
direction: z.enum(['asc', 'desc']).default('desc'),
});
export const validateQuery = (schema: z.ZodTypeAny) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
req.validatedQuery = result.data;
next();
};
};
Architecture Rationale:
safeParse prevents unhandled exceptions from crashing the event loop.
z.coerce handles string-to-number conversion safely without parseInt quirks.
- Middleware attaches validated data to the request object, ensuring downstream handlers never touch raw
req.query.
- Error responses are structured for client-side form handling and API gateway logging.
2. Parameterized Data Access in TypeORM
String interpolation in query builders or Raw() operators bypasses driver-level escaping. We enforce parameterized bindings and whitelist allowed columns.
Implementation:
import { Repository, SelectQueryBuilder } from 'typeorm';
import { UserEntity } from './user.entity';
export class UserRepository {
constructor(private readonly repo: Repository<UserEntity>) {}
async findWithFilters(filters: { role?: string; status?: string }) {
const qb: SelectQueryBuilder<UserEntity> = this.repo.createQueryBuilder('u');
if (filters.role) {
qb.andWhere('u.role = :targetRole', { targetRole: filters.role });
}
if (filters.status) {
qb.andWhere('u.status = :targetStatus', { targetStatus: filters.status });
}
return qb.getMany();
}
async searchByName(term: string) {
return this.repo.find({
where: {
name: this.repo.createQueryBuilder('u')
.where('u.name ILIKE :searchTerm', { searchTerm: `%${term}%` })
.getQuery(),
},
});
}
}
Architecture Rationale:
- Parameterized bindings (
:targetRole) delegate escaping to the database driver, eliminating SQL injection vectors.
- QueryBuilder chaining prevents string concatenation entirely.
ILIKE is used for case-insensitive matching, but the pattern is injected via parameters, not template literals.
- Repository pattern isolates data access logic, making security audits and query optimization centralized.
3. NoSQL Operator Sanitization in Mongoose
MongoDB drivers interpret objects with $ prefixed keys as query operators. Unfiltered request bodies can manipulate query logic (e.g., { password: { $ne: null } }).
Implementation:
import mongoose, { FilterQuery } from 'mongoose';
import { UserDocument } from './user.model';
export function sanitizeMongoQuery<T extends Record<string, unknown>>(input: T): FilterQuery<T> {
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (key.startsWith('$')) {
throw new Error(`Forbidden query operator: ${key}`);
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
throw new Error(`Nested objects are not permitted in query filters`);
}
cleaned[key] = value;
}
return cleaned as FilterQuery<T>;
}
// Usage in service layer
export async function authenticateUser(credentials: { email: string; password: string }) {
const safeFilter = sanitizeMongoQuery(credentials);
const user = await mongoose.model<UserDocument>('User').findOne(safeFilter);
return user ?? null;
}
Architecture Rationale:
- Explicit rejection of
$ prefixed keys and nested objects prevents operator injection.
- Type casting to
FilterQuery<T> maintains TypeScript safety while enforcing runtime constraints.
- Service layer isolation ensures sanitization happens before ODM interaction.
4. Prototype Pollution Neutralization
Deep merging untrusted payloads mutates Object.prototype. Schema validation inherently blocks this by rejecting undeclared keys.
Implementation:
import { z } from 'zod';
const AppConfigSchema = z.object({
maxRetries: z.number().int().min(0).max(10),
timeoutMs: z.number().int().positive(),
enableCache: z.boolean(),
});
export function applyConfiguration(partial: unknown) {
const parsed = AppConfigSchema.safeParse(partial);
if (!parsed.success) {
throw new Error('Configuration update rejected: invalid schema');
}
// parsed.data contains only whitelisted keys
// __proto__, constructor, and arbitrary keys are stripped automatically
return parsed.data;
}
Architecture Rationale:
- Zod's default behavior strips unknown keys, eliminating prototype pollution vectors.
- Explicit schema definition acts as a property whitelist, preventing configuration drift.
- No recursive merge logic is required, reducing cognitive complexity and attack surface.
5. Cryptographic Verification & Environment Integrity
JWT verification must constrain algorithms, and environment variables require startup validation to prevent silent failures.
Implementation:
import jwt from 'jsonwebtoken';
import { z } from 'zod';
const EnvSchema = z.object({
JWT_PUBLIC_KEY: z.string().min(256),
JWT_ALGORITHM: z.enum(['RS256', 'ES256']).default('RS256'),
DATABASE_URL: z.string().url(),
});
export const runtimeEnv = EnvSchema.parse(process.env);
export function verifyToken(token: string): jwt.JwtPayload {
const decoded = jwt.verify(token, runtimeEnv.JWT_PUBLIC_KEY, {
algorithms: [runtimeEnv.JWT_ALGORITHM],
clockTolerance: 30,
});
if (typeof decoded === 'string') {
throw new Error('Invalid token structure');
}
return decoded;
}
Architecture Rationale:
algorithms array prevents algorithm confusion attacks (e.g., none or HS256 with a public key).
clockTolerance accounts for minor server time drift without weakening security.
- Startup validation fails the process immediately if secrets are missing or malformed, adhering to fail-fast principles.
- Type narrowing ensures
decoded is always a payload object, preventing runtime type errors.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Compile-Time Illusion | Assuming interface UserInput { id: number } prevents malicious payloads. Types are erased at runtime and provide zero validation. | Implement runtime schema validation (Zod/class-validator) at every network boundary. Treat types as documentation, not security. |
| ORM Abstraction Leakage | Trusting that TypeORM/Mongoose auto-sanitize inputs. Raw queries, Raw(), and object spreading bypass driver escaping. | Use parameterized bindings exclusively. Never concatenate strings into query methods. Audit query builders for template literals. |
| Unbounded Object Merging | Recursive deep merge on req.body mutates Object.prototype via __proto__ or constructor keys. | Replace merge logic with schema validation. Whitelist allowed properties and strip unknown keys automatically. |
| Algorithm Negotiation Ambiguity | Omitting algorithms in jwt.verify() allows attackers to forge tokens using weaker algorithms or the none algorithm. | Explicitly constrain algorithms to ['RS256'] or ['ES256']. Never rely on library defaults in production. |
| Decode vs Verify Confusion | Using jwt.decode() for authentication checks. It parses the payload without verifying the signature, enabling trivial token forgery. | Always use jwt.verify() with a public key and algorithm constraint. Reserve decode() for logging or debugging only. |
| Silent Environment Failures | Missing .env variables or malformed database URLs cause runtime crashes or fallback to insecure defaults. | Validate environment variables at startup using a schema parser. Fail the process immediately if constraints are violated. |
| Template Context Confusion | Passing raw user input directly into EJS, Nunjucks, or Pug templates. Unescaped output enables Server-Side Template Injection (SSTI). | Enable auto-escaping in template engines. Sanitize inputs with context-aware libraries before rendering. Never trust user-generated HTML. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput public API | Zod + functional middleware | Fast parsing, automatic type inference, minimal boilerplate | Low (1.5-2.5% overhead) |
| Enterprise internal services | class-validator + DTO classes | Decorator-based validation aligns with NestJS/Express patterns | Low (2.0-3.0% overhead) |
| Complex reporting queries | Raw SQL with parameterized bindings | Performance optimization while maintaining driver-level escaping | Negligible |
| Dynamic user settings | Schema-driven configuration | Prevents prototype pollution and configuration drift automatically | Low |
| Multi-tenant SaaS | Strict algorithm constraints + env validation | Eliminates token forgery and cross-tenant secret leakage | Low |
Configuration Template
// src/config/security-bootstrap.ts
import { z } from 'zod';
import jwt from 'jsonwebtoken';
export const SecurityConfig = z.object({
JWT_PUBLIC_KEY: z.string().min(256, 'Public key must be at least 256 characters'),
JWT_ALGORITHM: z.enum(['RS256', 'ES256']).default('RS256'),
DATABASE_URL: z.string().url('Must be a valid database connection string'),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000),
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100),
});
export type SecurityConfig = z.infer<typeof SecurityConfig>;
export function initializeSecurity(): SecurityConfig {
const config = SecurityConfig.parse(process.env);
// Verify key format before starting server
try {
jwt.verify('dummy', config.JWT_PUBLIC_KEY, { algorithms: [config.JWT_ALGORITHM] });
} catch (error) {
if (error instanceof jwt.JsonWebTokenError && error.message.includes('invalid signature')) {
// Expected: dummy token will fail, but key format is valid
} else {
throw new Error('JWT public key validation failed');
}
}
console.info(`Security bootstrap complete. Algorithm: ${config.JWT_ALGORITHM}`);
return config;
}
Quick Start Guide
- Install Dependencies:
npm install zod jsonwebtoken class-validator (choose one validation library based on your framework)
- Create Bootstrap Script: Copy the configuration template into
src/config/security-bootstrap.ts and import it at your application entry point
- Add Validation Middleware: Implement the
validateQuery or validateBody middleware pattern and attach it to route definitions
- Audit Query Builders: Search your codebase for template literals inside
.query(), .where(), or .find() and replace with parameterized bindings
- Enable Template Escaping: Configure your template engine (EJS/Nunjucks/Pug) to auto-escape output and sanitize dynamic content before rendering
Runtime security is not a feature; it is an architectural constraint. By enforcing contracts at the network boundary, parameterizing data access, and validating cryptographic boundaries at startup, TypeScript applications achieve production-grade resilience without sacrificing developer velocity.