Skip to content

Secure Backend Development

Intermediate

Security patterns for backend development with Node.js/NestJS/Express: input validation, authentication guards, RBAC middleware, secure ORM usage with Prisma/Mongoose, error handling that does not leak internals, and secure deployment practices.

Key Facts

  • ValidationPipe with whitelist: true strips unknown properties; forbidNonWhitelisted: true rejects them
  • NestJS Guards execute before controllers - use for authentication and authorization
  • Prisma/Mongoose use parameterized queries by default - raw queries bypass protection
  • Never expose database errors to users - they reveal schema information
  • Environment variables for secrets (DATABASE_URL, JWT_SECRET) - never commit to git
  • HttpOnly cookies for session tokens prevent XSS-based session theft

Input Validation

NestJS Validation Pipe

// Global pipe
app.useGlobalPipes(new ValidationPipe({
    whitelist: true,           // Strip unknown properties
    forbidNonWhitelisted: true // Reject unknown properties
}));

DTOs with class-validator

export class CreateMovieDto {
    @IsString()
    @IsNotEmpty()
    title: string;

    @IsString()
    @IsOptional()
    description?: string;

    @IsArray()
    @IsString({ each: true })
    genres: string[];
}

Express Validation

const { body, validationResult } = require('express-validator');

app.post('/api/register', [
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 6 }),
    body('name').trim().notEmpty()
], async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
    }
});

Authentication Guards

NestJS JWT Guard

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

// Usage on controller
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: User) { ... }

Express Auth Middleware

const auth = (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];
    if (!token) return res.status(401).json({ error: 'No token' });
    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.userId = decoded.userId;
        next();
    } catch (e) {
        res.status(401).json({ error: 'Invalid token' });
    }
};

RBAC (Role-Based Access Control)

@Injectable()
export class RolesGuard implements CanActivate {
    constructor(private reflector: Reflector) {}
    canActivate(context: ExecutionContext): boolean {
        const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
        if (!requiredRoles) return true;
        const { user } = context.switchToHttp().getRequest();
        return requiredRoles.some(role => user.roles?.includes(role));
    }
}

// Custom decorator
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

// Usage
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
create(@Body() dto: CreateDto) { ... }

Error Handling

// NestJS Exception Filter - safe error responses
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
    catch(exception: HttpException, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const status = exception.getStatus();
        response.status(status).json({
            statusCode: status,
            message: exception.message,    // Safe message only
            timestamp: new Date().toISOString(),
        });
        // Log full error internally, never send to client
    }
}

Secure ORM Usage

Prisma

// Safe - parameterized
const user = await prisma.user.findUnique({ where: { id: userId } });

// Safe - nested creates
const log = await prisma.log.create({
    data: {
        userId: req.userId,
        exercises: { create: [{ name: "Bench Press" }] }
    },
    include: { exercises: true }
});

// DANGEROUS - raw query with interpolation
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${unsafeInput}`;

Mongoose (MongoDB)

// Prevent NoSQL injection
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize());  // Strips $ operators from user input

NestJS Architecture Patterns

Middleware (Logging)

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
    use(req: Request, res: Response, next: NextFunction) {
        console.log(`${req.method} ${req.url} - ${new Date().toISOString()}`);
        next();
    }
}

Interceptors (Response Transform)

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
    intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
        return next.handle().pipe(map(data => ({ data, statusCode: 200 })));
    }
}

Custom Decorators

export const CurrentUser = createParamDecorator(
    (data: unknown, ctx: ExecutionContext) => {
        return ctx.switchToHttp().getRequest().user;
    },
);

Deployment Security

  • Environment variables for all secrets
  • HTTPS via reverse proxy (Nginx)
  • Process manager (PM2) for Node.js
  • helmet middleware for security headers
  • Rate limiting (express-rate-limit)
  • CORS configuration (restrictive whitelist)
  • Disable X-Powered-By header

Serverless Backend Security

// Lambda/Cloud Function: validate event source
export async function handler(event: APIGatewayProxyEvent) {
    // Always validate event structure - it could come from any trigger
    if (!event.body || typeof event.body !== 'string') {
        return { statusCode: 400, body: 'Invalid request' };
    }

    // Parse with schema validation
    const parsed = JSON.parse(event.body);
    const validated = schema.safeParse(parsed); // zod
    if (!validated.success) {
        return { statusCode: 400, body: 'Validation failed' };
    }
}

Secrets Management

// Never hardcode secrets - use environment + secrets manager
// BAD
const API_KEY = "sk-1234567890";

// GOOD - from env (set by deployment, not committed)
const API_KEY = process.env.API_KEY;

// BETTER - from secrets manager with caching
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

let cachedSecret: string | null = null;
async function getSecret(name: string): Promise<string> {
    if (cachedSecret) return cachedSecret;
    const client = new SecretsManagerClient({});
    const response = await client.send(new GetSecretValueCommand({ SecretId: name }));
    cachedSecret = response.SecretString!;
    return cachedSecret;
}

Rate Limiting Patterns

// Express with sliding window
import rateLimit from 'express-rate-limit';

// Global rate limit
app.use(rateLimit({
    windowMs: 15 * 60 * 1000,  // 15 minutes
    max: 100,                   // 100 requests per window
    standardHeaders: true,
    legacyHeaders: false,
}));

// Per-endpoint stricter limit for auth
app.use('/api/auth', rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,                     // 5 login attempts per 15 min
    skipSuccessfulRequests: true,
}));

Gotchas

  • whitelist: true alone does NOT reject extra fields - it silently strips them. Add forbidNonWhitelisted: true to reject
  • Prisma $queryRaw with template literals IS safe (tagged template), but string concatenation is NOT
  • NestJS @Body() without ValidationPipe does no validation at all - pipe must be applied
  • MongoDB $where operator allows JavaScript execution - never use with user input
  • express-mongo-sanitize must be applied BEFORE route handlers to be effective
  • Rate limiting by IP alone is insufficient - attackers use IP rotation. Combine with user ID, API key, or fingerprint
  • helmet() sets good defaults but Content-Security-Policy needs manual tuning per app - test with report-only mode first
  • Secrets in Lambda environment variables are visible via console.log(process.env) - never log full env in production

See Also