Copyright © 2024. All rights reserved.
A complete guide to implementing custom audit logging in a Node.js Express application running on AWS ECS Fargate, using CloudWatch as the monitoring and querying backbone.
The audit logging pipeline follows a simple but effective flow. Your Express application writes structured JSON audit entries to stdout. The ECS Fargate awslogs log driver automatically ships those entries to a CloudWatch Log Group. From there, CloudWatch Logs Insights provides a powerful SQL-like query engine to search, filter, and aggregate your audit data. CloudWatch Metric Filters can transform log patterns into metrics, which feed into alarms and dashboards — all deployable via CloudFormation.
Express App (stdout) → awslogs driver → CloudWatch Logs → Logs Insights / Dashboards / Alarms
This approach requires no additional SDKs, no sidecar containers, and no extra infrastructure cost beyond standard CloudWatch pricing.
Create an audit-logger.js file that serves as both a standalone logging utility and an Express middleware. The module provides two main exports: a logAudit function for manual audit entries on sensitive operations, and an auditMiddleware for automatic request-level auditing.
// audit-logger.ts
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
// --- Types ---
export const AUDIT_ACTIONS = {
CREATE: 'CREATE',
READ: 'READ',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
LOGIN: 'LOGIN',
LOGOUT: 'LOGOUT',
ACCESS_DENIED: 'ACCESS_DENIED',
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
export interface AuditDetails {
statusCode?: number;
duration_ms?: number;
error?: string;
reason?: string;
mfa_used?: boolean;
[key: string]: unknown;
}
export interface AuditEntry {
audit: true;
traceId: string;
timestamp: string;
user_id: string;
action: AuditAction | string;
resource: string;
status: 'SUCCESS' | 'FAILED';
method?: string;
path?: string;
ip_address?: string;
user_agent?: string;
details: AuditDetails | null;
}
export interface AuditLogParams {
userId?: string;
action: AuditAction | string;
resource: string;
status: 'SUCCESS' | 'FAILED';
req?: Request;
details?: AuditDetails;
}
export interface AuditMiddlewareOptions {
excludePaths?: string[];
}
// Extend Express Request to include user
interface AuthenticatedRequest extends Request {
user?: {
id: string;
role?: string;
[key: string]: unknown;
};
}
// --- Core Functions ---
function createAuditEntry({
userId,
action,
resource,
status,
req,
details,
}: AuditLogParams): AuditEntry {
const authReq = req as AuthenticatedRequest | undefined;
return {
audit: true,
traceId: req?.headers?.['x-trace-id'] as string || randomUUID(),
timestamp: new Date().toISOString(),
user_id: userId || authReq?.user?.id || 'anonymous',
action,
resource,
status,
method: req?.method,
path: req?.originalUrl,
ip_address:
(req?.headers?.['x-forwarded-for'] as string) ||
req?.socket?.remoteAddress,
user_agent: req?.headers?.['user-agent'],
details: details || null,
};
}
export function logAudit(params: AuditLogParams): AuditEntry {
const entry = createAuditEntry(params);
process.stdout.write(JSON.stringify(entry) + '\n');
return entry;
}
// --- HTTP Method to Action Mapping ---
const METHOD_ACTION_MAP: Record<string, AuditAction> = {
GET: 'READ',
POST: 'CREATE',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
function mapMethodToAction(method: string): AuditAction | string {
return METHOD_ACTION_MAP[method] || method;
}
// --- Express Middleware ---
export function auditMiddleware(
options: AuditMiddlewareOptions = {}
): (req: Request, res: Response, next: NextFunction) => void {
const { excludePaths = ['/health', '/ready', '/metrics'] } = options;
return (req: Request, res: Response, next: NextFunction): void => {
if (excludePaths.some((p) => req.path.startsWith(p))) {
next();
return;
}
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const action = mapMethodToAction(req.method);
const status: 'SUCCESS' | 'FAILED' =
res.statusCode >= 400 ? 'FAILED' : 'SUCCESS';
logAudit({
userId: (req as AuthenticatedRequest).user?.id,
action,
resource: req.originalUrl,
status,
req,
details: {
statusCode: res.statusCode,
duration_ms: duration,
...(res.statusCode >= 400 && { error: res.statusMessage }),
},
});
});
next();
};
}
The audit: true field acts as a filter marker, making it easy to separate audit entries from regular application logs in CloudWatch queries. The traceId field supports distributed tracing — if your upstream services pass an x-trace-id header, the audit log carries it through. Otherwise, a UUID is generated. Writing to process.stdout instead of console.log avoids the overhead of console formatting and ensures clean single-line JSON output that CloudWatch can parse natively.
Wire the audit middleware into your Express app as a global middleware. This automatically logs every request, while the logAudit function handles manual entries for sensitive operations that need extra detail.
// app.ts
import express, { Request, Response } from 'express';
import { auditMiddleware, logAudit, AUDIT_ACTIONS } from './audit-logger';
const app = express();
app.use(express.json());
// Auto-audit all requests
app.use(
auditMiddleware({
excludePaths: ['/health', '/ready', '/favicon.ico'],
})
);
// Login
app.post('/api/login', async (req: Request, res: Response) => {
const { email, password } = req.body;
try {
const user = await authenticateUser(email, password);
logAudit({
userId: user.id,
action: AUDIT_ACTIONS.LOGIN,
resource: '/api/login',
status: 'SUCCESS',
req,
details: { mfa_used: user.mfaEnabled },
});
res.json({ token: generateToken(user) });
} catch (err) {
logAudit({
userId: email,
action: AUDIT_ACTIONS.LOGIN,
resource: '/api/login',
status: 'FAILED',
req,
details: { reason: (err as Error).message },
});
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Sensitive delete
app.delete('/api/orders/:id', async (req: Request, res: Response) => {
const order = await Order.findById(req.params.id);
logAudit({
userId: (req as any).user.id,
action: AUDIT_ACTIONS.DELETE,
resource: `orders/${req.params.id}`,
status: 'SUCCESS',
req,
details: {
orderId: req.params.id,
orderValue: order.total,
customerAffected: order.customerId,
},
});
await order.delete();
res.json({ message: 'Order deleted' });
});
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok' });
});
app.listen(3000, () => console.log('Server running on port 3000'));
For operations like authentication, deletions, or permission changes, add explicit audit entries with richer context.
Authentication Example:
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
try {
const user = await authenticateUser(email, password);
logAudit({
userId: user.id,
action: AUDIT_ACTIONS.LOGIN,
resource: '/api/login',
status: 'SUCCESS',
req,
details: { mfa_used: user.mfaEnabled }
});
res.json({ token: generateToken(user) });
} catch (err) {
logAudit({
userId: email,
action: AUDIT_ACTIONS.LOGIN,
resource: '/api/login',
status: 'FAILED',
req,
details: { reason: err.message }
});
res.status(401).json({ error: 'Invalid credentials' });
}
});
Sensitive Delete Example:
app.delete('/api/orders/:id', authorize('admin'), async (req, res) => {
const order = await Order.findById(req.params.id);
logAudit({
userId: req.user.id,
action: AUDIT_ACTIONS.DELETE,
resource: `orders/${req.params.id}`,
status: 'SUCCESS',
req,
details: {
orderId: req.params.id,
orderValue: order.total,
customerAffected: order.customerId
}
});
await order.delete();
res.json({ message: 'Order deleted' });
});
Each audit entry appears in CloudWatch as a single JSON line:
{
"audit": true,
"traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"timestamp": "2026-03-01T10:30:45.123Z",
"user_id": "user-123",
"action": "DELETE",
"resource": "orders/456",
"status": "SUCCESS",
"method": "DELETE",
"path": "/api/orders/456",
"ip_address": "203.0.113.50",
"user_agent": "Mozilla/5.0 ...",
"details": {
"statusCode": 200,
"duration_ms": 45,
"orderId": "456",
"orderValue": 299.99,
"customerAffected": "cust-789"
}
}
Configure your ECS task definition to route container logs to CloudWatch using the awslogs log driver. Setting awslogs-create-group to true allows the log group to be created automatically if it doesn't exist.
{
"family": "my-express-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::...:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "express-app",
"image": "your-ecr-repo/express-app:latest",
"portMappings": [
{ "containerPort": 3000, "protocol": "tcp" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-express-app",
"awslogs-region": "ap-southeast-2",
"awslogs-stream-prefix": "app",
"awslogs-create-group": "true"
}
},
"environment": [
{ "name": "NODE_ENV", "value": "production" }
]
}
]
}
Make sure your ECS task execution role has the following permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:ap-southeast-2:*:log-group:/ecs/my-express-app:*"
}
]
}
Logs Insights provides a SQL-like query language to search and analyse your audit data. Below are the most useful queries for audit log analysis.
fields @timestamp, user_id, action, resource, status, ip_address
| filter audit = 1
| sort @timestamp desc
| limit 100
fields @timestamp, action, resource, status, details.statusCode
| filter audit = 1 and user_id = "user-123"
| sort @timestamp desc
fields @timestamp, user_id, ip_address
| filter audit = 1 and action = "LOGIN" and status = "FAILED"
| stats count(*) as attempts by user_id, ip_address
| filter attempts > 5
| sort attempts desc
fields @timestamp, user_id, resource, details.orderId, details.orderValue
| filter audit = 1 and action = "DELETE"
| sort @timestamp desc
fields path, details.duration_ms
| filter audit = 1
| stats avg(details.duration_ms) as avg_ms,
pct(details.duration_ms, 95) as p95_ms,
count(*) as hits by path
| sort p95_ms desc
fields ip_address, user_id, action
| filter audit = 1
| stats count(*) as requests,
count_distinct(user_id) as unique_users by ip_address
| filter unique_users > 3
| sort requests desc
fields @timestamp, action, status
| filter audit = 1 and status = "FAILED"
| stats count(*) as errors by bin(15m)
| sort @timestamp asc
fields @timestamp, user_id, resource, ip_address, details.reason
| filter audit = 1 and action = "ACCESS_DENIED"
| sort @timestamp desc
Deploy the full monitoring infrastructure as code. This template creates a dedicated audit log group with a 1-year retention policy, metric filters to convert log patterns into CloudWatch metrics, alarms for suspicious activity, and a dashboard combining audit and operational data.
AWSTemplateFormatVersion: '2010-09-09'
Description: Audit Logging Infrastructure for ECS Fargate Express App
Parameters:
ClusterName:
Type: String
Default: my-ecs-cluster
ServiceName:
Type: String
Default: my-express-app
LogGroupName:
Type: String
Default: /ecs/my-express-app
AlertEmail:
Type: String
Description: Email address for alarm notifications
Resources:
# --- Log Group with Retention ---
AuditLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Ref LogGroupName
RetentionInDays: 365
# --- Metric Filters ---
FailedLoginFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref AuditLogGroup
FilterPattern: '{ $.audit = true && $.action = "LOGIN" && $.status = "FAILED" }'
MetricTransformations:
- MetricNamespace: !Sub "${ServiceName}/Audit"
MetricName: FailedLogins
MetricValue: "1"
DefaultValue: 0
FailedActionsFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref AuditLogGroup
FilterPattern: '{ $.audit = true && $.status = "FAILED" }'
MetricTransformations:
- MetricNamespace: !Sub "${ServiceName}/Audit"
MetricName: FailedActions
MetricValue: "1"
DefaultValue: 0
DeleteActionsFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref AuditLogGroup
FilterPattern: '{ $.audit = true && $.action = "DELETE" }'
MetricTransformations:
- MetricNamespace: !Sub "${ServiceName}/Audit"
MetricName: DeleteActions
MetricValue: "1"
DefaultValue: 0
AccessDeniedFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref AuditLogGroup
FilterPattern: '{ $.audit = true && $.action = "ACCESS_DENIED" }'
MetricTransformations:
- MetricNamespace: !Sub "${ServiceName}/Audit"
MetricName: AccessDeniedEvents
MetricValue: "1"
DefaultValue: 0
# --- SNS Topic for Alerts ---
AuditAlertTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub "${ServiceName}-audit-alerts"
AuditAlertSubscription:
Type: AWS::SNS::Subscription
Properties:
TopicArn: !Ref AuditAlertTopic
Protocol: email
Endpoint: !Ref AlertEmail
# --- Alarms ---
BruteForceAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${ServiceName}-brute-force-detection"
AlarmDescription: "More than 20 failed logins in 5 minutes"
Namespace: !Sub "${ServiceName}/Audit"
MetricName: FailedLogins
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 20
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AuditAlertTopic
HighFailureRateAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${ServiceName}-high-failure-rate"
AlarmDescription: "More than 50 failed actions in 5 minutes"
Namespace: !Sub "${ServiceName}/Audit"
MetricName: FailedActions
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 50
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AuditAlertTopic
ExcessiveDeletesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "${ServiceName}-excessive-deletes"
AlarmDescription: "More than 30 delete operations in 5 minutes"
Namespace: !Sub "${ServiceName}/Audit"
MetricName: DeleteActions
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 30
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AuditAlertTopic
# --- Dashboard ---
AuditDashboard:
Type: AWS::CloudWatch::Dashboard
Properties:
DashboardName: !Sub "${ServiceName}-audit-dashboard"
DashboardBody: !Sub |
{
"widgets": [
{
"type": "metric",
"x": 0, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "CPU & Memory Utilization",
"metrics": [
["AWS/ECS", "CPUUtilization", "ClusterName", "${ClusterName}", "ServiceName", "${ServiceName}"],
["AWS/ECS", "MemoryUtilization", "ClusterName", "${ClusterName}", "ServiceName", "${ServiceName}"]
],
"period": 300, "stat": "Average", "region": "${AWS::Region}", "view": "timeSeries"
}
},
{
"type": "metric",
"x": 12, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "Audit Metrics",
"metrics": [
["${ServiceName}/Audit", "FailedLogins"],
["${ServiceName}/Audit", "FailedActions"],
["${ServiceName}/Audit", "DeleteActions"],
["${ServiceName}/Audit", "AccessDeniedEvents"]
],
"period": 300, "stat": "Sum", "region": "${AWS::Region}", "view": "timeSeries"
}
},
{
"type": "log",
"x": 0, "y": 6, "width": 12, "height": 6,
"properties": {
"title": "Failed Logins by User",
"query": "SOURCE '${LogGroupName}' | fields @timestamp, user_id, ip_address\n| filter audit = 1 and action = 'LOGIN' and status = 'FAILED'\n| stats count(*) as attempts by user_id, ip_address\n| sort attempts desc",
"region": "${AWS::Region}", "view": "table"
}
},
{
"type": "log",
"x": 12, "y": 6, "width": 12, "height": 6,
"properties": {
"title": "Error Rate (15m Buckets)",
"query": "SOURCE '${LogGroupName}' | fields @timestamp\n| filter audit = 1 and status = 'FAILED'\n| stats count(*) as errors by bin(15m)",
"region": "${AWS::Region}", "view": "bar"
}
},
{
"type": "log",
"x": 0, "y": 12, "width": 24, "height": 6,
"properties": {
"title": "Recent Audit Events",
"query": "SOURCE '${LogGroupName}' | fields @timestamp, user_id, action, resource, status, ip_address\n| filter audit = 1\n| sort @timestamp desc\n| limit 25",
"region": "${AWS::Region}", "view": "table"
}
},
{
"type": "log",
"x": 0, "y": 18, "width": 12, "height": 6,
"properties": {
"title": "Slowest Endpoints (P95)",
"query": "SOURCE '${LogGroupName}' | fields path, details.duration_ms\n| filter audit = 1\n| stats avg(details.duration_ms) as avg_ms, pct(details.duration_ms, 95) as p95_ms, count(*) as hits by path\n| sort p95_ms desc",
"region": "${AWS::Region}", "view": "table"
}
},
{
"type": "log",
"x": 12, "y": 18, "width": 12, "height": 6,
"properties": {
"title": "DELETE Operations",
"query": "SOURCE '${LogGroupName}' | fields @timestamp, user_id, resource, details.orderValue\n| filter audit = 1 and action = 'DELETE'\n| sort @timestamp desc\n| limit 20",
"region": "${AWS::Region}", "view": "table"
}
}
]
}
Outputs:
DashboardURL:
Description: CloudWatch Dashboard URL
Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#dashboards:name=${ServiceName}-audit-dashboard"
LogGroupURL:
Description: CloudWatch Logs URL
Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#logsV2:log-groups/log-group/${LogGroupName}"
AlertTopicArn:
Description: SNS Topic ARN for audit alerts
Value: !Ref AuditAlertTopic
aws cloudformation deploy \
--template-file audit-infrastructure.yaml \
--stack-name my-app-audit \
--parameter-overrides \
ClusterName=my-ecs-cluster \
ServiceName=my-express-app \
LogGroupName=/ecs/my-express-app \
AlertEmail=anil@example.com
For audit events that should also produce custom CloudWatch metrics without needing metric filters, use the CloudWatch Embedded Metric Format (EMF). This lets you emit both a log entry and a metric in a single stdout write.
function logAuditWithMetric({ userId, action, resource, status, req, durationMs }) {
const entry = {
_aws: {
Timestamp: Date.now(),
CloudWatchMetrics: [
{
Namespace: 'MyApp/Audit',
Dimensions: [['Action'], ['Action', 'Status']],
Metrics: [
{ Name: 'AuditEventCount', Unit: 'Count' },
{ Name: 'ActionDuration', Unit: 'Milliseconds' },
],
},
],
},
audit: true,
user_id: userId,
Action: action,
resource,
Status: status,
AuditEventCount: 1,
ActionDuration: durationMs,
};
process.stdout.write(JSON.stringify(entry) + '\n');
}
For compliance requirements beyond CloudWatch's retention limits, export audit logs to S3 via a Kinesis Data Firehose subscription filter. Add this to your CloudFormation template to stream audit events to an S3 bucket for indefinite storage and offline analysis.
If you need richer visualisation, Amazon Managed Grafana can query CloudWatch as a data source. This gives you access to more chart types, alerting options, and the ability to combine audit metrics with data from other sources like Prometheus or OpenSearch in a single dashboard.
The audit logging pipeline for ECS Fargate with Node.js Express follows a straightforward pattern. Your Express app writes structured JSON to stdout using the audit logger module and middleware. The awslogs driver ships those entries to CloudWatch automatically, with zero additional infrastructure. CloudFormation deploys metric filters that convert log patterns into CloudWatch metrics, which feed into alarms for security events like brute force login attempts and excessive deletions. A CloudWatch dashboard ties everything together, giving you real-time visibility into both operational health and audit activity. The entire stack is version-controlled, reproducible, and costs only standard CloudWatch pricing.
Copyright © 2024. All rights reserved.