Skip to main content

Security Best Practices

This guide covers security best practices for building secure applications with MBC CQRS Serverless, including input validation, authentication, authorization, and data protection.

Input Validation

Validate All Input

Always validate input at the API boundary using class-validator decorators.

import {
IsNotEmpty,
IsString,
IsEmail,
IsOptional,
MaxLength,
MinLength,
Matches,
IsNumber,
Min,
Max,
ValidateNested,
IsArray,
ArrayMaxSize,
} from 'class-validator';
import { Type } from 'class-transformer';

export class OrderItemDto {
@IsNotEmpty()
@IsString()
productId: string;

@IsNumber()
@Min(1)
quantity: number;
}

export class CreateOrderDto {
@IsNotEmpty()
@IsString()
@MaxLength(100)
@Matches(/^[a-zA-Z0-9\s\-]+$/, {
message: 'Name contains invalid characters',
})
name: string;

@IsNotEmpty()
@IsString()
@Matches(/^[A-Z0-9\-]+$/, {
message: 'Code must be uppercase alphanumeric with hyphens',
})
@MaxLength(50)
code: string;

@IsOptional()
@IsEmail()
customerEmail?: string;

@IsNumber()
@Min(0)
@Max(1000000)
amount: number;

@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items?: OrderItemDto[];
}

Sanitize String Input

Prevent XSS and injection attacks by sanitizing string input.

import { Transform } from 'class-transformer';
import * as sanitizeHtml from 'sanitize-html';

export class CommentDto {
@IsString()
@MaxLength(1000)
@Transform(({ value }) => sanitizeHtml(value, {
allowedTags: [], // Strip all HTML
allowedAttributes: {},
}))
content: string;

@IsString()
@MaxLength(500)
@Transform(({ value }) => sanitizeHtml(value, {
allowedTags: ['b', 'i', 'em', 'strong'], // Allow basic formatting
allowedAttributes: {},
}))
description: string;
}

Code Injection Prevention

Never use dynamic code execution constructs or build shell commands from user-controlled strings. These patterns are flagged as AP022 and AP023 by the MCP anti-pattern checker.

Avoid eval() and new Function():

// Forbidden — executes arbitrary code from user input
const result = eval(userInput);
const fn = new Function('return ' + userInput)();

// Safe — use a fixed dispatch table instead
const OPERATIONS: Record<string, (a: number, b: number) => number> = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
};
const op = OPERATIONS[userInput]; // Look up only — never eval
if (!op) throw new BadRequestException('Unknown operation');
const result = op(1, 2);

Avoid shell commands built from string concatenation:

import { execSync } from 'child_process';

// Forbidden — shell injection risk when userInput contains special characters
const output = execSync(`ls ${userInput}`);

// Safe — use AWS SDK or fixed commands; never interpolate user input into shell strings
import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3';
const client = new S3Client({});
await client.send(new ListObjectsV2Command({ Bucket: 'my-bucket', Prefix: sanitizedPrefix }));

Validate File Uploads

Restrict file types, sizes, and scan for malware.

import { BadRequestException } from '@nestjs/common';
import FileType from 'file-type'; // npm install file-type

// Validate file type and size
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'application/pdf',
'text/csv',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

async function validateUpload(file: Express.Multer.File): Promise<void> {
// Check MIME type
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException('File type not allowed');
}

// Check file size
if (file.size > MAX_FILE_SIZE) {
throw new BadRequestException('File too large');
}

// Verify file signature (magic bytes)
const fileType = await FileType.fromBuffer(file.buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime)) {
throw new BadRequestException('Invalid file content');
}
}

Authentication

Configure Cognito Securely

Use strong password policies and MFA.

// CDK configuration for Cognito User Pool
const userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: false, // Disable self-registration if not needed
signInAliases: {
email: true,
username: false,
},
passwordPolicy: {
minLength: 12,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
tempPasswordValidity: Duration.days(7),
},
mfa: cognito.Mfa.REQUIRED,
mfaSecondFactor: {
sms: true,
otp: true,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
advancedSecurityMode: cognito.AdvancedSecurityMode.ENFORCED,
});
Default Implementation Note

The default implementation uses minLength: 6 for development convenience. For production environments, we strongly recommend using at least 12 characters as shown above, along with MFA enabled.

Validate JWT Tokens

Always validate JWT tokens on the server side.

import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { CognitoJwtVerifier } from 'aws-jwt-verify';

@Injectable()
export class JwtAuthGuard implements CanActivate {
private verifier: CognitoJwtVerifier;

constructor() {
this.verifier = CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID,
tokenUse: 'access',
clientId: process.env.COGNITO_USER_POOL_CLIENT_ID,
});
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);

if (!token) {
throw new UnauthorizedException('Missing token');
}

try {
const payload = await this.verifier.verify(token);
request.user = payload;
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}

private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
return authHeader.substring(7);
}
}

Token Refresh Strategy

Implement secure token refresh without storing refresh tokens on client.

// Frontend token refresh
async function refreshTokenIfNeeded(): Promise<string> {
try {
const session = await Auth.currentSession();
return session.getAccessToken().getJwtToken();
} catch (error) {
if (error.name === 'NotAuthorizedException') {
// Redirect to login
await Auth.signOut();
throw new Error('Session expired');
}
throw error;
}
}

Authorization

Implement Role-Based Access Control

Use the framework's @Auth decorator, which applies RolesGuard and checks the roles carried in the custom:roles JWT claim (and group-derived roles from custom:groups). See Authentication for the full role model.

import { Controller, Get } from '@nestjs/common';
import { Auth, ROLE_SYSTEM_ADMIN } from '@mbc-cqrs-serverless/core';

// Usage in controller
@Controller('admin')
export class AdminController {
@Get('users')
@Auth(ROLE_SYSTEM_ADMIN)
findAllUsers() {
// Only users holding the required role can access
}
}
AP027: GroupRoleResolver + @Injectable() Conflict

When implementing group-based role resolution (v1.3.1+), do not annotate your resolver class with both @GroupRoleResolver() and @Injectable(). @GroupRoleResolver() already registers the class as a singleton provider — adding @Injectable() creates a conflicting second registration that causes a NestJS DI error at startup.

// ❌ Wrong — AP027 anti-pattern
@Injectable()
@GroupRoleResolver()
export class MyGroupRoleResolver implements IGroupRoleResolver { ... }

// ✅ Correct — only @GroupRoleResolver()
@GroupRoleResolver()
export class MyGroupRoleResolver implements IGroupRoleResolver { ... }

See Group-Based Roles for implementation details and Common Issues for troubleshooting.

Enforce Tenant Isolation

Always derive the tenant code from the verified JWT via getUserContext() — never from request parameters or the request body. The framework's RolesGuard verifies tenant access (including the x-tenant-code header override for common tenants).

import { Get } from '@nestjs/common';
import {
getUserContext,
IInvoke,
INVOKE_CONTEXT,
} from '@mbc-cqrs-serverless/core';

@Get('orders')
async listOrders(@INVOKE_CONTEXT() invokeContext: IInvoke) {
// The tenant code comes from the verified token, not from user input
const { tenantCode } = getUserContext(invokeContext);
return this.orderService.listOrders(tenantCode);
}

// Always include tenant in queries
async function getOrdersByTenant(invokeContext: IInvoke) {
const { tenantCode } = getUserContext(invokeContext);

// Tenant is always part of the partition key
return this.dataService.listItemsByPk(`ORDER#${tenantCode}`);
}

Resource-Level Authorization

Check ownership before allowing operations.

import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { getUserContext, IInvoke, ROLE_SYSTEM_ADMIN } from '@mbc-cqrs-serverless/core';

// In a service class with dataService and commandService injected
async updateOrder(
pk: string, // e.g., 'ORDER#tenant-a'
sk: string, // e.g., 'order-123'
updates: UpdateOrderDto,
invokeContext: IInvoke,
): Promise<CommandModel | null> {
// Derive user identity from verified JWT — never from request parameters
const { userId, tenantRoles } = getUserContext(invokeContext);
const order = await this.dataService.getItem({ pk, sk });

if (!order) {
throw new NotFoundException('Order not found');
}

// Check ownership — allow system_admin to bypass
const isAdmin = tenantRoles.includes(ROLE_SYSTEM_ADMIN);
if (order.createdBy !== userId && !isAdmin) {
throw new ForbiddenException('Not authorized to update this order');
}

return this.commandService.publishPartialUpdateSync({
pk: order.pk,
sk: order.sk,
version: order.version,
...updates,
}, { invokeContext });
}

Data Protection

Encrypt Sensitive Data

Encrypt sensitive data before storing.

import * as crypto from 'crypto';

const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; // 32 bytes for AES-256
const IV_LENGTH = 16;

function encrypt(text: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(
'aes-256-gcm',
Buffer.from(ENCRYPTION_KEY, 'hex'),
iv,
);

let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');

const authTag = cipher.getAuthTag();

return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}

function decrypt(encryptedText: string): string {
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');

const decipher = crypto.createDecipheriv(
'aes-256-gcm',
Buffer.from(ENCRYPTION_KEY, 'hex'),
Buffer.from(ivHex, 'hex'),
);

decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));

let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
}

// Usage
const sensitiveData = {
ssn: encrypt('123-45-6789'),
creditCard: encrypt('4111111111111111'),
};

Mask Sensitive Data in Logs

Never log sensitive information.

// Create a logger that masks sensitive fields
const SENSITIVE_FIELDS = ['password', 'token', 'ssn', 'creditCard', 'secret'];

function maskSensitiveData(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj;
}

const masked = Array.isArray(obj) ? [...obj] : { ...obj };

for (const key of Object.keys(masked)) {
if (SENSITIVE_FIELDS.some(field =>
key.toLowerCase().includes(field.toLowerCase())
)) {
masked[key] = '***MASKED***';
} else if (typeof masked[key] === 'object') {
masked[key] = maskSensitiveData(masked[key]);
}
}

return masked;
}

// Usage
console.log('Request:', maskSensitiveData(requestBody));
warning

Never log process.env or a full HTTP request/event object. process.env exposes every environment variable (including secrets and API keys) to the log stream. A full request object exposes all headers including the Authorization token. This is detected as AP025 by the MCP anti-pattern checker.

// Forbidden — leaks all secrets in process.env and request auth headers
this.logger.debug('Env:', process.env);
this.logger.debug('Request:', event);

// Safe — log only specific, non-sensitive fields
this.logger.debug('App:', { name: process.env.APP_NAME, env: process.env.NODE_ENV });
this.logger.debug('Request:', { path: event.rawPath, method: event.requestContext?.http?.method });

Secure Environment Variables

Use AWS Secrets Manager for sensitive configuration.

import {
SecretsManagerClient,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

const secretsClient = new SecretsManagerClient({});

async function getSecret(secretName: string): Promise<Record<string, string>> {
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await secretsClient.send(command);

if (response.SecretString) {
return JSON.parse(response.SecretString);
}

throw new Error('Secret not found');
}

// Cache secrets to avoid repeated API calls
let cachedSecrets: Record<string, string> | null = null;

async function getDatabasePassword(): Promise<string> {
if (!cachedSecrets) {
cachedSecrets = await getSecret('myapp/database');
}
return cachedSecrets.password;
}

API Security

Rate Limiting

Implement rate limiting to prevent abuse.

// API Gateway throttling in serverless.yml
provider:
apiGateway:
throttling:
burstLimit: 200
rateLimit: 100

// Per-function throttling
functions:
createOrder:
handler: handler.createOrder
events:
- http:
path: orders
method: post
throttling:
burstLimit: 10
rateLimit: 5

CORS Configuration

Configure CORS strictly.

// serverless.yml
provider:
httpApi:
cors:
allowedOrigins:
- https://myapp.example.com
- https://admin.example.com
allowedHeaders:
- Content-Type
- Authorization
- X-Request-Id
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
maxAge: 3600
Default Implementation Note

The default implementation uses allowedOrigins: ['*'] for development convenience. For production environments, you should always restrict allowed origins to specific domains as shown above to prevent cross-site request forgery (CSRF) attacks.

Request Size Limits

Limit request payload size to prevent DoS.

// API Gateway payload limit in serverless.yml
provider:
apiGateway:
binaryMediaTypes:
- 'application/octet-stream'
maximumPayloadSize: 10485760 # 10MB

// Application-level validation
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
}),
)
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// Process file
}

Infrastructure Security

Least Privilege IAM Policies

Grant only necessary permissions to Lambda functions.

# serverless.yml
provider:
iam:
role:
statements:
# DynamoDB access - specific tables only
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:Query
Resource:
- !GetAtt OrderTable.Arn
- !Sub '${OrderTable.Arn}/index/*'

# S3 access - specific bucket and prefix
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
Resource:
- !Sub 'arn:aws:s3:::${UploadBucket}/uploads/*'

# Secrets Manager - specific secrets only
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource:
- !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:myapp/*'

VPC Configuration

Deploy Lambda functions in VPC for database access.

# serverless.yml
provider:
vpc:
securityGroupIds:
- !Ref LambdaSecurityGroup
subnetIds:
- !Ref PrivateSubnet1
- !Ref PrivateSubnet2

resources:
Resources:
LambdaSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Lambda security group
VpcId: !Ref VPC
SecurityGroupEgress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0 # HTTPS outbound only
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
DestinationSecurityGroupId: !Ref RDSSecurityGroup

Enable AWS CloudTrail

Log all API calls for audit.

// CDK configuration
const trail = new cloudtrail.Trail(this, 'AuditTrail', {
bucket: auditBucket,
sendToCloudWatchLogs: true,
cloudWatchLogsRetention: logs.RetentionDays.ONE_YEAR,
includeGlobalServiceEvents: true,
isMultiRegionTrail: true,
});

// Log specific events
trail.addEventSelector(cloudtrail.DataResourceType.DYNAMODB_TABLE, [
`arn:aws:dynamodb:${this.region}:${this.account}:table/*`,
]);

Security Checklist

Pre-Deployment

  • All input is validated with class-validator
  • Sensitive data is encrypted at rest
  • JWT token validation is implemented
  • Role-based access control is configured
  • Tenant isolation is enforced
  • Rate limiting is configured
  • CORS is configured strictly
  • IAM policies follow least privilege
  • Secrets are stored in Secrets Manager
  • VPC is configured for database access

Post-Deployment

  • CloudTrail is enabled
  • CloudWatch alarms are configured
  • Security groups are reviewed
  • API Gateway throttling is verified
  • Penetration testing is completed