Middleware Design
Middleware Design Document
Project:
{{PROJECT_NAME}}Drop Version:{{VERSION}}0.1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Platform Architect (AI) Status:Draft |In Review| ApprovedReviewers:{{REVIEWERS}}Alem Bašić (CEO)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft from source code analysis |
1. Middleware Pipeline Overview
Drop
sequenceDiagramhas participanttwo Clientmiddleware participantlayers:
CORS
as 1. CORS
participant Security as 2. Security Headers
participant RequestID as 3. Request ID
participant RateLimit as 4. Rate Limiter
participant Logger as 5. Request Logger
participant Auth as 6. Authentication
participant Authz as 7. Authorization (RBAC)
participant Validate as 8. Validation
participant AuditLog as 9. Audit Logger
participant Handler as Route Handler
Client->>CORS: HTTP Request
CORS->>Security: (CORS headers set)
Security->>RequestID: (Security headers set)
RequestID->>RateLimit: (X-Request-ID injected)
RateLimit->>Logger: (Rate check passed)
Logger->>Auth: (Request logged)
Auth->>Authz: (JWT validated, user attached)
Authz->>Validate: (Permissions verified)
Validate->>AuditLog: (Input validated & sanitized)
AuditLog->>Handler: (Audit record written)
Handler-->>Client: Response
Framework: {{NestJS / Express / Fastify / Hono}}src/lib/middleware.ts
Execution order is strict — changingThe orderactive maymiddleware breakused securityby guarantees.all API routes. Provides requireAuth, requireMerchant, rateLimit, getClientIp, jsonError, CSRF protection, and session revocation.
src/lib/middleware/ — A modular middleware library with auth-middleware.ts (Bearer token for mobile), error-handler.ts (AppError class), and validation.ts (input sanitization functions).
Both layers are used in production. Routes import from @/lib/middleware (auth, rate limiting) and @/lib/middleware/validation (input validation).
2. RequestActive LifecycleMiddleware (lib/middleware.ts)
2.1 CORS MiddlewarerequireAuth(request?)
Library:Source: middleware.ts:42–80
Authenticates the current request via cookie-based JWT.
Returns: { user: User, error: null } | {cors /user: @fastify/cors}null, error: NextResponse }
Configuration:Steps:
- CSRF origin check — if
Originheader present, must match allowed origins (NEXT_PUBLIC_APP_URL,http://localhost:3000,http://localhost:3001) - Cookie extraction — reads
drop_tokenfrom request cookies - JWT verification — validates HS256 signature and expiry using
joselibrary - User lookup — loads user from
userstable byuserIdfrom JWT payload - Session revocation check — verifies at least one non-revoked session exists for this user
Usage:
// config/cors.config.ts
export const corsConfig{ user, error } = {await origin: (origin: string, callback: Function) => {
const allowedOrigins = [
'https://app.{{domain.com}}',
'https://admin.{{domain.com}}',
...(process.env.NODE_ENV !== 'production'
? ['http://localhost:3000', 'http://localhost:3001']
: []),
]requireAuth(request);
if (!originerror) ||return allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Request-ID', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'],
credentials: true,
maxAge: 86400,error; // 24hReturns preflightNextResponse cachewith };JSON error
// user is guaranteed non-null here
PerformanceError impact:responses:
2.2 Security Headers MiddlewarerequireMerchant(request?)
Library:Source: helmetmiddleware.ts:101–108
Extends requireAuth with a merchant role check.
app.use(helmet(const { user, error } = await requireMerchant(request);
if (error) return error; // Content401 Securityif Policynot contentSecurityPolicy:authenticated, {403 directives:if {not defaultSrc:merchant
["
Returns 403 forbidden if user exists but role !== '.self'"]merchant'
Applied to: GET /api/merchants/dashboard, scriptSrc:GET , ["'self'"]/api/merchants/qrstyleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://cdn.{{domain.com}}"],
connectSrc: ["'self'", "https://api.{{domain.com}}"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
},
},GET //api/merchants/transactions
2.3 TransportrateLimit(ip, Securitylimit, hsts:windowMs?)
Source: maxAge:middleware.ts:7–31
Persistent //IP-based 1rate yearlimiter includeSubDomains:using true,the preload:rate_limits true,database },table.
| Parameter | Default | Description |
|---|---|---|
ip |
— | Client |
limit |
— | Max |
windowMs |
60,000ms | Window size in milliseconds |
Returns: boolean — true if request is allowed, false if rate limited.
Implementation:
- Uses
runUpsertfor atomic counter creation/update - Cleans expired entries on each call (
legacyremovesbrowsers)rowshidePoweredBy:wheretrue,expires_at)//<Removenow - Counter
})stored inrate_limitstable:(key, count, expires_at)
Rate limit table schema:
CREATE TABLE rate_limits (
key TEXT PRIMARY KEY, -- IP address
count INTEGER DEFAULT 1,
expires_at INTEGER -- Unix timestamp (ms)
);
Usage:
const ip = getClientIp(request);
if (!(await rateLimit(ip, 10))) { // 10 req/min
return jsonError("rate_limited", "Too many requests", 429);
}
Applied limits:
| Endpoint | Limit | Window |
|---|---|---|
/api/auth/bankid/initiate |
10/min | 60s |
/api/auth/bankid/callback |
10/min | 60s |
/api/auth/register (deprecated) |
10/min | 60s |
/api/auth/login (deprecated) |
10/min | 60s |
/api/transactions/remittance |
10/min | 60s |
/api/transactions/qr-payment |
10/min | 60s |
/api/rates |
120/min | 60s |
/api/rates/[currency] |
120/min | 60s |
2.4 getClientIp(request)
Source: middleware.ts:33–35
Extracts the client's real IP address from the x-forwarded-for header (first IP in the chain — the originating client). Falls back to '127.0.0.1' if header not present.
Note: When behind App Runner (AWS managed proxy), x-forwarded-for is set automatically with the real client IP.
2.5 jsonError(error, message, status, details?)
Source: middleware.ts:37–39
Creates a standardized JSON error NextResponse.
return jsonError("validation_error", "Validation failed", 422, ["Email required"]);
// Response body: { "error": "validation_error", "message": "Validation failed", "details": ["Email required"] }
2.6 revokeAllSessions(userId)
Source: middleware.ts:83–85
Sets revoked=1 on all sessions for a user. Called by POST /api/auth/logout.
UPDATE sessions SET revoked = 1 WHERE user_id = $1;
2.7 generateCsrfToken() / validateCsrf(request, token)
Source: middleware.ts:88–99
CSRF token generation (32 random bytes hex-encoded) and validation via x-csrf-token header.
Status: Implemented but not actively required on any route. CSRF protection is handled via:
- BankID OIDC state parameter (login flow)
- Origin header validation (in
requireAuth)
3. Middleware Library (lib/middleware/)
3.1 Error Handler (middleware/error-handler.ts)
AppError class:
class AppError extends Error {
constructor(
public code: string,
message: string,
public status: number = 500,
public details?: unknown
) {}
}
Predefined error constructors:
| Constructor | Code | HTTP Status |
|---|---|---|
Errors.unauthorized(msg?) |
UNAUTHORIZED |
401 |
Errors.forbidden(msg?) |
FORBIDDEN |
403 |
Errors.notFound(resource) |
NOT_FOUND |
404 |
Errors.badRequest(msg, details?) |
BAD_REQUEST |
400 |
Errors.conflict(msg) |
CONFLICT |
409 |
Errors.tooManyRequests(msg?) |
RATE_LIMIT_EXCEEDED |
429 |
Errors.internal(msg?) |
INTERNAL_ERROR |
500 |
Error response format:
{
"error": {
"code": "BAD_REQUEST",
"message": "Amount must be between 100 and 50000 NOK",
"details": "validation_error"
}
}
Production masking: createErrorResponse() masks internal error messages in production — only returns "An unexpected error occurred" for 500 errors.
3.2 Auth Middleware (middleware/auth-middleware.ts)
Alternative auth middleware for mobile clients using Bearer token pattern.
requireAuth(request):
- Extracts JWT from
Authorization: Bearer <token>header - Verifies JWT signature + expiry
- Returns
userIdfrom payload
In-memory rate limiter (for Bearer token routes):
DEFAULT_RATE_LIMIT: 100 req/minSTRICT_RATE_LIMIT: 10 req/min- Auto-cleanup every 5 minutes
- Rate limit headers:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
getClientIP(request):
Checks X-Forwarded-For → X-Real-IP → falls back to 'unknown'.
3.3 Validation (middleware/validation.ts)
Input validation functions — no external dependencies, all custom implementations.
| Function | Description | Rules |
|---|---|---|
validatePhone(phone) |
International phone | Starts with +, 8–15 digits |
validateAmount(amount) |
Positive monetary amount | > 0, max 2 decimal places |
validateIBAN(iban) |
European IBAN | Country code + alphanumeric, mod-97 checksum |
validatePIN(pin) |
Card PIN | Exactly 4 digits |
validateEmail(email) |
Email address | Basic [email protected] pattern |
validateCurrency(currency) |
ISO 4217 code | Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR |
validateDateISO(date) |
ISO 8601 date | Parseable by Date.parse() |
validateName(name) |
Name field | 1–100 chars, at least one letter, XSS-safe |
validateLanguage(lang) |
Language code | Whitelist: nb, en, bs, sq |
sanitizeText(text, maxLength?) |
Text sanitization | Strips HTML tags + control chars, trims, enforces max length (default 500) |
validate(condition, msg) |
Assert helper | Throws AppError (400) if false |
required(value, name) |
Required field check | Throws AppError (400) if null/undefined |
Security notes:
validateNamechecks for:<script,javascript:,onerror=,onclick=— blocks XSS injection in name fieldssanitizeTextremoves HTML tags via regex, strips control charactersvalidateIBANimplements full mod-97 checksum algorithmvalidateAmountrejectsNaN,Infinity, negative values
4. Security Headers set:(Next.js Config)
Applied to all responses via next.config.ts:
| Header | Production Value | Development Value | Purpose |
|---|---|---|---|
| | ||
Content-Security-Policy |
default-src |
Adds 'unsafe-eval' + 'unsafe-inline' for HMR |
XSS |
X-Frame-Options |
DENY |
DENY |
Clickjacking prevention |
X-Content-Type-Options |
nosniff |
nosniff |
MIME sniffing prevention |
Referrer-Policy |
|
Same | Referrer |
Permissions-Policy |
camera=(self), microphone=(), geolocation=(self) |
Same | Feature restriction |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Same | Force HTTPS (2-year HSTS) |
Performance impact: < 0.2ms per request
2.3 Request ID Middleware
Purpose: Correlate logs across services for distributed tracing.
// middleware/request-id.middleware.ts
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const requestId = req.headers['x-request-id'] as string
|| `req_${ulid()}`;
req.requestId = requestId;
res.setHeader('X-Request-ID', requestId);
// Bind to AsyncLocalStorage for log correlation
requestContext.run({ requestId }, next);
}
Format: req_{ulid} — e.g., req_01HX7M2K5N3P4Q5R6S7T8V9W0
2.4 Authentication Middleware
Strategy: JWT Bearer token validation
// guards/jwt.guard.ts
@Injectable()
export class JwtGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('No token provided');
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get('JWT_SECRET'),
algorithms: ['HS256'],
clockTolerance: 10, // 10 second clock skew tolerance
});
// Attach to request for downstream use
request.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
};
return true;
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new UnauthorizedException('TOKEN_EXPIRED');
}
throw new UnauthorizedException('INVALID_TOKEN');
}
}
private extractToken(request: Request): string | null {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : null;
}
}
Performance impact: ~2-5ms (crypto operation + optional DB lookup for token revocation)
Token revocation check: {{Check Redis blocklist on each request | Check on logout only | Use short TTL — no revocation check}}
2.5 Authorization5. Middleware (RBAC/ABAC)
Model: {{RBAC (Role-Based) | ABAC (Attribute-Based) | Hybrid}}
// decorators/roles.decorator.ts
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
export const RequirePermission = (permission: string) =>
SetMetadata('permission', permission);
// guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<Role[]>('roles', context.getHandler());
const requiredPermission = this.reflector.get<string>('permission', context.getHandler());
if (!requiredRoles && !requiredPermission) return true; // Public route
const { user } = context.switchToHttp().getRequest();
if (requiredRoles && !requiredRoles.includes(user.role)) {
throw new ForbiddenException(`Requires role: ${requiredRoles.join(' or ')}`);
}
if (requiredPermission && !this.hasPermission(user, requiredPermission)) {
throw new ForbiddenException(`Requires permission: ${requiredPermission}`);
}
return true;
}
}
// Usage on controller
@Get('users')
@Roles(Role.ADMIN)
@RequirePermission('users:read')
async listUsers() { ... }
Role hierarchy:
admin > manager > user > viewer > public
Matrix
requireAuth |
requireMerchant |
Feature Flag | Validation Functions | ||
|---|---|---|---|---|---|
GET |
No | No | No | — | |
GET |
No | No | No | state cookie | |
GET |
Yes | No | No | — | |
POST |
Yes | No | No | — | |
POST /api/auth/refresh |
No | Yes | No | No | — |
GET /api/transactions |
No | Yes | No | No | — |
POST /api/transactions/remittance |
10/min | Yes | No | No | validateAmount |
POST /api/transactions/qr-payment |
10/min | Yes | No | No | validateAmount |
GET /api/rates |
120/min | No | No | No | — |
POST /api/recipients |
No | Yes | No | No | validateName, country whitelist |
POST /api/merchants/register |
No | Yes | No | No | validateName, orgNumber |
GET /api/merchants/dashboard |
No | Yes | Yes | No | period whitelist |
GET /api/notifications |
No | Yes | No | notifications |
— |
PATCH /api/notifications |
No | Yes | No | notifications |
ID format, max 100 |
PATCH /api/settings |
No | Yes | No | No | currency/language whitelist |
POST /api/cards/[id]/physical |
No | Yes | No | physicalCards |
address min 10 chars |
POST /api/cards/[id]/pin |
No | Yes | No | cardPin |
validatePIN |
GET/PUT /api/cards/[id]/limits |
No | Yes | No | spendingLimits |
limitType whitelist |
2.66. ValidationError Middleware
Spike Detection
Library:Implemented in class-validator + class-transformersrc/lib/alerts.tsORas zod
//middleware-adjacent Global validation pipe (NestJS)
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw if unknown properties present
transform: true, // Auto-transform to DTO types
transformOptions: {
enableImplicitConversion: true,
},
}));
Sanitization rules:concern:
AllEverystringHTTPinputs:5xxtrimresponsewhitespacetriggerstrackError()(called injsonError()middleware for 500 errors)HTMLRollingcontent:1-minutesanitizewindowwithoferrorDOMPurify/timestampsmaintainedsanitize-html(strip dangerous tags)in-memorySQLWhenparameters:countalways>use5parameterizedinqueries60(ORMsecondshandles→this)sends critical Slack alert to#drop-opsFile10-minuteuploads:cooldownvalidateperMIMEalerttypetitlebypreventsmagic bytes (not just extension)spam
Validation error format:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "must be an email" },
{ "field": "name", "message": "must be longer than 2 characters" }
]
}
}
Performance impact:Limitation: <Error 1mscounter is in-memory only — resets on application restart. Redis-backed counter planned for typical DTOs
2.7 Rate Limiting Middleware
Library: {{@nestjs/throttler | express-rate-limit | rate-limiter-flexible}}
Storage: {{Redis}} (shared across all replicas)
Algorithms:
| ||
| ||
|
Selected algorithm: {{Sliding window}}
Configuration:
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl',
points: 1000, // Number of points
duration: 60, // Per 60 seconds
blockDuration: 60, // Block for 60s after exceeded
});
// Per-route overrides
const loginLimiter = new RateLimiterRedis({
points: 5,
duration: 900, // 15 minutes
blockDuration: 900,
});
Key strategy: {{IP address | User ID (if authenticated) | IP + User ID}}
Performance impact: ~1-2ms (Redis round-trip)
2.8 Audit Logging Middleware
// interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, path, user, requestId } = request;
// Only audit mutating operations
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
this.auditService.log({
requestId,
userId: user?.id,
method,
path,
body: this.sanitizeBody(request.body), // Strip PII
timestamp: new Date().toISOString(),
});
}
return next.handle();
}
private sanitizeBody(body: Record<string, unknown>) {
const REDACTED_FIELDS = ['password', 'token', 'creditCard', 'ssn'];
return Object.fromEntries(
Object.entries(body).map(([key, value]) =>
REDACTED_FIELDS.includes(key) ? [key, '[REDACTED]'] : [key, value]
)
);
}
}
What IS logged:
User ID, request ID, timestamp, method, pathResponse status code, durationMutation summaries (what changed, not full values)
What is NEVER logged:
Passwords, tokens, API keysPayment card dataFull PII fields (log field names but not values for sensitive fields)
Audit log retention: {{1 year}} (compliance requirement: {{GDPR / SOC2 / internal}})
2.9 Error Handling Middleware
// filters/global-exception.filter.ts
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = 500;
let code = 'INTERNAL_ERROR';
let message = 'An unexpected error occurred';
let details: unknown[] = [];
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse() as any;
code = exceptionResponse.code ?? 'HTTP_ERROR';
message = exceptionResponse.message ?? exception.message;
details = exceptionResponse.details ?? [];
}
// Log 5xx errors (not 4xx — those are client errors)
if (status >= 500) {
this.logger.error('Unhandled exception', { exception, requestId: request.requestId });
this.sentryService.captureException(exception);
}
response.status(status).json({
error: {
code,
message,
details,
requestId: request.requestId,
timestamp: new Date().toISOString(),
},
});
}
}
3. Custom Middleware Development Guide
Template for new middleware:
// middleware/{{name}}.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class {{Name}}Middleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
// 1. Extract needed data from request
// 2. Perform validation/enrichment/logging
// 3. Set data on request object if needed
// 4. Call next() or throw HttpException
next();
}
}
Requirements for new middleware:
Handles errors without crashing the processCallsnext()exactly once (or throws)Does not block async operations without async/awaitPerformance impact documentedUnit tests covering happy path + error path
4. Middleware Ordering & Dependencies
Request → [1] → [2] → [3] → [4] → [5] → [6] → [7] → [8] → [9] → Handler
[1] CORS — No dependencies
[2] Security — No dependencies
[3] Request ID — Must be before Logger (Logger reads requestId)
[4] Rate Limiter — Must be after Request ID (uses requestId for key)
[5] Logger — Must be after Request ID
[6] Auth — Must be after Logger (Logger should log auth failures)
[7] Authorization — MUST be after Auth (requires user on request)
[8] Validation — MUST be after Auth (DTOs may reference user context)
[9] Audit Logger — MUST be after Auth (logs user ID)
NEVER reorder middleware without reviewing this dependency chain.v1.0.
5.Related Performance Impact Per MiddlewareDocuments
Target: middleware overhead < 10ms P50, < 25ms P99.
6. Testing Strategy for Middleware
// Example unit test for Auth middleware
describe('JwtGuard', () => {
it('should attach user to request on valid token', async () => {
const token = generateTestToken({ sub: 'usr_123', role: 'user' });
const mockRequest = { headers: { authorization: `Bearer ${token}` } };
const result = await guard.canActivate(createMockContext(mockRequest));
expect(result).toBe(true);
expect(mockRequest.user).toMatchObject({ id: 'usr_123', role: 'user' });
});
it('should throw UnauthorizedException on expired token', async () => {
const expiredToken = generateExpiredToken();
const mockRequest = { headers: { authorization: `Bearer ${expiredToken}` } };
await expect(guard.canActivate(createMockContext(mockRequest)))
.rejects.toThrow('TOKEN_EXPIRED');
});
});
Test coverage requirements:
EachBackendmiddleware: ≥ 90% line coverageArchitectureSecurityAPImiddlewareReference- Source:
AuthZ, Validation): 100% branch coverageMIDDLEWARE.md
7. Configuration Options Per Middleware
| | ||
| | ||
| | ||
| | ||
| | ||
| |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Platform Architect (AI) | 2026-02-23 | |
| Alem | |||
| Bašić |