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 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;
}

Validate File Uploads

Restrict file types, sizes, and scan for malware.

// 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,
});

Validate JWT Tokens

Always validate JWT tokens on the server side.

import { Injectable, CanActivate, ExecutionContext } 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_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 Cognito groups for role-based authorization.

import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common';

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

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);

if (!requiredRoles) {
return true;
}

const { user } = context.switchToHttp().getRequest();
const userGroups = user['cognito:groups'] || [];

return requiredRoles.some(role => userGroups.includes(role));
}
}

// Usage in controller
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
@Get('users')
@Roles('admin', 'super-admin')
findAllUsers() {
// Only admin or super-admin can access
}
}

Enforce Tenant Isolation

Always verify tenant access in multi-tenant applications.

@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
const requestedTenant = request.params.tenantCode || request.body?.tenantCode;

if (!requestedTenant) {
return true; // No tenant context required
}

const userTenants = user['custom:tenantCodes']?.split(',') || [];

if (!userTenants.includes(requestedTenant)) {
throw new ForbiddenException('Access denied to this tenant');
}

return true;
}
}

// Always include tenant in queries
async function getOrdersByTenant(tenantCode: string, userId: string) {
// Verify user has access to tenant first
const user = await this.userService.getUser(userId);
if (!user.tenantCodes.includes(tenantCode)) {
throw new ForbiddenException('Access denied');
}

return this.dataService.listItems({
pk: `ORDER#${tenantCode}`,
// Tenant is always part of the query
});
}

Resource-Level Authorization

Check ownership before allowing operations.

async function updateOrder(
orderId: string,
updates: UpdateOrderDto,
userId: string,
): Promise<Order> {
const order = await this.dataService.getItem({ pk, sk });

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

// Check ownership
if (order.createdBy !== userId && !this.isAdmin(userId)) {
throw new ForbiddenException('Not authorized to update this order');
}

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

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));

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

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

See Also