_Why platforms in the adult industry face some of the most aggressive attack surfaces on the web — and how to build a system that holds up using NestJS, React Router, and a strict three-environment pipeline.
_
Running a platform in the adult industry means operating in one of the harshest security environments in commercial web development. That's not hyperbole — it's what we see in our logs every single day.
The attack vectors are wider than those of a typical SaaS product. Users store highly sensitive information: personal data, payment details, photos, and private communications. A breach isn't just a PR disaster — it can destroy lives. The market is competitive and not always above-board: competitors order targeted DDoS attacks or attempt to harvest user databases. Regulatory requirements (GDPR, and soon the EU Digital Services Act) demand verifiably documented security measures.
In our monitoring we see daily credential-stuffing waves, scraper botnets, SQL injection attempts, and targeted attacks on WebSocket endpoints. A "that won't happen to us" attitude is simply not an option.
Security can't be an afterthought. It has to be baked into the architecture — from the first line of code to the production deployment. This is what that looks like in practice.
1. Why Adult Platforms Need a Different Threat Model
Most web applications have to protect their users' data. Adult platforms have to protect their users' identities.
That's a fundamentally different bar. Consider the data that lives on a typical escort platform: real names, phone numbers, location history, banking details, profile photos that users explicitly don't want associated with their identity elsewhere, and private messages between users who trust the platform to keep those conversations confidential. A breach of this data doesn't just mean spam emails — it means real-world consequences: outing, blackmail, career damage, relationship breakdown.
This shapes every architectural decision we make. We don't ask "what is the minimum security we need to comply?" — we ask "what happens to our users if this particular piece of data leaks?" That question has a way of focusing the mind.
Practically speaking, the threat landscape looks like this:
- Credential stuffing: Automated bots testing leaked username/password combinations from other breaches against our login endpoint. This is constant.
- Scraping: Botnets systematically harvesting profile data to build competing datasets or blackmail users.
- Targeted attacks: Competitors or bad actors directly targeting infrastructure — DDoS, attempted database exfiltration, social engineering of support staff.
- WebSocket exploitation: Our real-time messaging system is a favourite target; attackers probe for replay vulnerabilities, missing authentication on upgrade, and insecure deserialization.
- Regulatory probes: Authorities and NGOs scan platforms for CSAM, age verification bypasses, and GDPR non-compliance. These aren't malicious attacks, but they demand robust audit trails.
The threat model shapes the architecture. Let's get into it.
2. The Three-Environment Pipeline: Dev → Staging → Prod
The foundation of our security strategy is a strict three-environment separation. No feature, no hotfix, no configuration change ever lands directly in production.
Development
Local development uses mock services, a local Postgres instance, hot-reloading, full debug logging, and CORS open to localhost. The payment provider is faked. Secrets are fake. The goal is developer velocity — this environment is intentionally permissive because it is completely isolated from real user data.
Staging
Staging is where things get serious. It runs on production-equivalent infrastructure, with real TLS certificates, production-equivalent secrets (different values, same structure), and real data — anonymized and stripped of PII before being seeded. Every deployment to staging triggers the full security gate suite: SAST, dependency audits, container scanning, DAST. Load tests run here. Penetration tests target this environment.
The single most important rule: staging must be indistinguishable from production in terms of configuration. A security vulnerability that isn't visible in staging because the config differs will hit production without warning.
Production
Production deployments happen only after staging approval. Manual SSH access to production servers is impossible — there are no keys, no bastion hosts for developers. All deployments flow through the CI/CD pipeline. Debug output is completely disabled. The WAF is active. Rate limiting is strict. An audit log captures every meaningful action, immutably, in a separate append-only datastore.
We manage all three environments with Terraform, with strictly separated state files per environment. Environment-specific secrets live in HashiCorp Vault and are never shared across environments.
dev ──────────────┐
▼
staging ──── [security gates] ──── approval ──── prod
3. Security Gates in CI/CD
Before any code reaches staging or production, it passes through a series of automated security checks. These aren't optional quality checks — a failed gate stops the deployment completely.
git push → lint/typecheck → unit tests → SAST → dep audit → container scan → staging deploy → DAST → prod deploy
Static Application Security Testing (SAST)
We use semgrep with our own ruleset alongside eslint-plugin-security to scan every commit for known vulnerability patterns: SQL injection risks, missing input validation, hardcoded secrets, unsafe regex patterns (ReDoS), and insecure deserialization. The scan runs in under 30 seconds and blocks on any findings.
# .github/workflows/security.yml (relevant excerpt)
- name: SAST - Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/nodejs
p/secrets
p/owasp-top-ten
Dependency Auditing
Every npm audit run with --audit-level=high is a pipeline blocker. On top of that, we use socket.dev — which doesn't just check known CVEs but also detects suspicious behavioral changes in npm packages. Supply chain attacks via malicious package updates are underestimated as an attack vector, and socket.dev catches them before they land in our dependency tree.
Container Image Scanning
Docker images are scanned with trivy before being pushed to the registry. Base images are restricted to minimal Alpine or Distroless variants. No root user in containers, ever. The CI step fails if any HIGH or CRITICAL CVE is found in the image.
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
Dynamic Application Security Testing (DAST)
After every staging deployment, an OWASP ZAP baseline scan runs automatically against the staging environment. It tests for the OWASP Top 10: XSS, injection, security misconfiguration, broken access control, and more. Only when this scan returns clean does the production deployment gate open.
4. NestJS: Building the Backend as a Fortress
NestJS provides an excellent foundation — but a secure baseline doesn't emerge on its own. Here are the patterns we apply consistently.
Guards First: Locked by Default
Every route is locked by default. Authentication and authorization run through Guards, never through ad-hoc checks in controllers. The critical inversion: routes must explicitly opt into being public, not opt out of being protected.
// auth.guard.ts — global guard, no route is public by default
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.get(IS_PUBLIC_KEY, context.getHandler());
if (isPublic) return true;
return super.canActivate(context);
}
}
// main.ts — registered globally
app.useGlobalGuards(new JwtAuthGuard(reflector));
// Routes that should be public are explicitly decorated
@Public()
@Post('auth/login')
async login(@Body() dto: LoginDto) { ... }
Input Validation with class-validator
All incoming data is validated through DTOs with class-validator and class-transformer. Whitelist mode is active: properties not defined in the DTO are automatically stripped. forbidNonWhitelisted turns unknown properties into a 400 error, making it immediately obvious when a client is sending unexpected data.
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip unknown props
forbidNonWhitelisted: true, // or throw exception
transform: true, // auto-cast types
transformOptions: {
enableImplicitConversion: false // stay explicit
}
}));
// dto/create-profile.dto.ts
export class CreateProfileDto {
@IsString()
@Length(2, 100)
@Transform(({ value }) => sanitizeHtml(value))
name: string;
@IsEnum(ProfileCategory)
category: ProfileCategory;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
}
Rate Limiting and Throttling
With @nestjs/throttler we limit requests per IP and per endpoint. Login endpoints have particularly aggressive limits. After five failed attempts, the IP is temporarily blocked with exponential backoff. Additionally, nginx sits in front of the application with limit_req_zone as a first line of defense — it blocks volumetric attacks before they ever reach NestJS.
// app.module.ts
ThrottlerModule.forRoot([
{
name: 'short',
ttl: 1000,
limit: 10,
},
{
name: 'medium',
ttl: 10000,
limit: 50,
},
]),
// auth.controller.ts — stricter limit on login
@Throttle({ short: { limit: 3, ttl: 60000 } })
@Post('login')
async login(@Body() dto: LoginDto) { ... }
Security Headers with Helmet
helmet is mandatory — but configured, not just switched on. Content Security Policy is locked down to our specific domains. HSTS is set with a long max-age. X-Frame-Options prevents clickjacking.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https://cdn.our-domain.com'],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // only where unavoidable
connectSrc: ["'self'", 'wss://api.our-domain.com'],
frameAncestors: ["'none'"],
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
Audit Logging
Every sensitive action — login, logout, profile update, payment, admin action — is written to an append-only audit log with user ID, IP, timestamp, and action details. This log is stored separately from the application database and is write-only from the application's perspective. Even if the main database is compromised, the audit trail remains intact.
5. React Router: Security on the Frontend
Frontend security is routinely neglected. Yet the frontend is the first attack surface the user sees — and therefore the primary vector for social engineering, XSS, and token theft.
Route-Level Authorization
With React Router v7 we implement a ProtectedRoute wrapper that checks authentication and authorization state before any route renders. Token refresh happens automatically and transparently.
// components/ProtectedRoute.tsx
export function ProtectedRoute({ requiredRole }: { requiredRole?: Role }) {
const { user, isLoading } = useAuth();
if (isLoading) return <LoadingSpinner />;
if (!user) return <Navigate to="/login" replace />;
if (requiredRole && !user.roles.includes(requiredRole)) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />;
}
// routes.tsx
const router = createBrowserRouter([
{
element: <ProtectedRoute />,
children: [
{ path: '/dashboard', element: <Dashboard /> },
{ path: '/messages', element: <Messages /> },
{
element: <ProtectedRoute requiredRole={Role.ADMIN} />,
children: [
{ path: '/admin', element: <AdminPanel /> },
],
},
],
},
]);
Token Storage: Never localStorage
JWTs are never stored in localStorage. That's an open XSS entry point — any XSS on the page, including from a third-party script, can steal that token. Our approach:
-
Refresh tokens: stored as
httpOnly,Secure,SameSite=Strictcookies. JavaScript cannot access them at all. - Access tokens: stored only in memory (React Context / Zustand store). Short lifetime of 15 minutes. Gone on page reload, automatically refreshed via the httpOnly cookie.
// auth.context.tsx
interface AuthState {
accessToken: string | null; // in-memory only
user: User | null;
}
function useAuthStore() {
const [state, setState] = useState<AuthState>({
accessToken: null,
user: null,
});
const refresh = useCallback(async () => {
// Refresh token is sent automatically via httpOnly cookie
const res = await fetch('/api/auth/refresh', { credentials: 'include' });
if (!res.ok) return setState({ accessToken: null, user: null });
const { accessToken, user } = await res.json();
setState({ accessToken, user });
}, []);
return { ...state, refresh };
}
Common mistake:
localStorage.setItem('token', jwt)is the most popular security error in frontend development. Any XSS anywhere on the page — including in an embedded third-party script — can exfiltrate that token.
CSP Enforced by the Backend
Content Security Policy is set as an HTTP header — never as a meta tag. Meta tags can be bypassed via DOM injection. Inline scripts are forbidden. External scripts may only load from explicitly allowed domains. This is set in the NestJS helmet configuration and applies to every response.
6. Live Messaging with End-to-End Encryption
The messaging system is the most security-critical feature of our platform. Users expect absolute confidentiality. We implement real end-to-end encryption — the server never sees the plaintext of any message.
How It Works
Every user generates an asymmetric key pair on their first login: X25519 for key exchange, AES-256-GCM for message encryption. The private key never leaves the user's device. Only the public key is stored on the server.
// crypto.service.ts (client-side, using WebCrypto API)
// Generate key pair on first login
async function generateUserKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
{ name: 'X25519' },
true,
['deriveKey']
);
}
// Export public key for server storage
async function exportPublicKey(publicKey: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey('raw', publicKey);
return btoa(String.fromCharCode(...new Uint8Array(raw)));
}
// Private key is encrypted with a user-derived key and stored in IndexedDB
// It never travels to the server
async function storePrivateKey(privateKey: CryptoKey, userDerivedKey: CryptoKey) {
const exported = await crypto.subtle.exportKey('jwk', privateKey);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
userDerivedKey,
new TextEncoder().encode(JSON.stringify(exported))
);
await db.set('privateKey', { encrypted, iv });
}
Sending a Message: ECDH Key Exchange
When sending a message, the sender performs an ECDH key exchange with the recipient's public key, derives a shared session key, and encrypts the message with it. The result — ciphertext plus an ephemeral public key — is sent over WebSocket and stored on the server. The server is a pure transport and storage layer for encrypted blobs. It cannot read any message.
async function encryptMessage(
plaintext: string,
recipientPublicKey: CryptoKey,
senderPrivateKey: CryptoKey
): Promise<EncryptedMessage> {
// Derive shared secret via ECDH
const sharedKey = await crypto.subtle.deriveKey(
{ name: 'X25519', public: recipientPublicKey },
senderPrivateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
new TextEncoder().encode(plaintext)
);
return {
ciphertext: bufferToBase64(ciphertext),
iv: bufferToBase64(iv),
// Include ephemeral sender public key so recipient can derive the same shared secret
senderEphemeralPublicKey: await exportPublicKey(recipientPublicKey),
};
}
WebSocket Security
WebSocket connections are only accepted over WSS (TLS). A valid JWT is validated during the handshake, before the upgrade completes. Every message includes a timestamp and sequence number — replay attacks are detected and rejected. NestJS WebSocket Gateway handles this:
@WebSocketGateway({ cors: false, transports: ['websocket'] })
export class MessagingGateway implements OnGatewayConnection {
async handleConnection(client: Socket) {
const token = client.handshake.auth.token;
const payload = await this.jwtService.verifyAsync(token).catch(() => null);
if (!payload) {
client.emit('error', { message: 'Unauthorized' });
client.disconnect();
return;
}
client.data.userId = payload.sub;
}
}
Forward Secrecy matters: We use ephemeral keys per session. Even if a user's long-term private key is eventually compromised, past messages remain encrypted. This is a non-negotiable property for a platform handling sensitive communications.
7. Data Encryption at Rest and in Transit
In Transit: TLS 1.3 Only
All HTTP connections enforce TLS 1.3. TLS 1.0 and 1.1 are disabled. TLS 1.2 remains active as a fallback for older clients but is deprioritized in cipher negotiation. Certificates come from Let's Encrypt with automated renewal. Certificate Transparency is active — any unexpected certificate issuance for our domains triggers an alert.
Our nginx TLS configuration achieves an A+ on SSL Labs. The relevant settings:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:...';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
At Rest: Field-Level Encryption for Sensitive Data
Full disk encryption on the infrastructure level is a given, not a substitute for application-level encryption. Sensitive database fields — phone numbers, real names where stored, payment-related identifiers — are encrypted at the application level before being written to the database.
// encryption.service.ts
@Injectable()
export class EncryptionService {
private readonly algorithm = 'aes-256-gcm';
private readonly key: Buffer;
constructor(private configService: ConfigService) {
// Key loaded from Vault at startup, never from env vars
this.key = Buffer.from(configService.get('ENCRYPTION_KEY'), 'hex');
}
encrypt(plaintext: string): EncryptedField {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
return {
data: encrypted.toString('base64'),
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
};
}
decrypt(field: EncryptedField): string {
const decipher = crypto.createDecipheriv(
this.algorithm,
this.key,
Buffer.from(field.iv, 'base64')
);
decipher.setAuthTag(Buffer.from(field.tag, 'base64'));
return decipher.update(Buffer.from(field.data, 'base64'), undefined, 'utf8')
+ decipher.final('utf8');
}
}
Secret Management
No secrets in CI/CD logs. No secrets in code repositories. No secrets in environment variables that get passed around carelessly. We use HashiCorp Vault for all secret management with automatic rotation. Application secrets are loaded at startup and never logged. Database credentials rotate every 30 days automatically. If a credential leaks, the blast radius is bounded in time.
8. Incident Response and Monitoring
Security is not a set-and-forget problem. An attack will come. The question is how quickly you detect it and how coordinated your response is.
Alerting Stack
We run a layered monitoring stack: application logs go to Loki, metrics to Prometheus, dashboards in Grafana. Critical events — failed login attempts above threshold, unusual traffic spikes, 5xx rates above 0.1%, anomalous message volume — trigger immediate PagerDuty alerts with on-call escalation.
Specifically, we alert on:
- More than 10 failed logins from a single IP in 5 minutes
- Any admin action outside business hours
- Database query time spiking above p99 baseline (potential injection causing table scans)
- A new IP accessing more than 100 profiles in 10 minutes (scraper pattern)
- Any 5xx response from the payments service
Anomaly Detection
ML-based anomaly detection sounds sophisticated but in practice often starts as simple pattern matching: a user generating 500 profile views in 5 minutes is not a human. An IP systematically iterating sequential numeric user IDs is a scanner. These patterns are detected and lead to automatic temporary blocks. More sophisticated behavioral analytics (session velocity, geographic impossibility) layer on top as the platform scales.
Incident Response Playbook
Every team needs a written playbook — even if you hope never to use it. Our playbook defines: first response within 15 minutes of a P1 alert, isolation of the affected system, internal communication chain, external communication obligations (GDPR: 72-hour reporting deadline to the supervisory authority in the event of a data breach), and a mandatory post-mortem process.
Run tabletop exercises: "What do we do if the database is compromised tomorrow morning?" A playbook that only exists on paper is worth little in a real incident. We run a 90-minute tabletop exercise every quarter.
9. Security Checklist to Take Home
A summary of every control described in this article, usable as a review checklist for your own platform:
Infrastructure & Pipeline
- [ ] Three strictly separated environments (Dev / Staging / Prod) with independent secrets
- [ ] No manual deployment to production — CI/CD pipeline only
- [ ] SAST, dependency audit, and container scan as pipeline gates that block on failure
- [ ] DAST against staging after every deployment
- [ ] Infrastructure-as-Code with per-environment state files
- [ ] Secret rotation via Vault — no static, long-lived credentials
Backend (NestJS)
- [ ] Global auth guard: routes opt-in to being public, not opt-out of being protected
- [ ] ValidationPipe with
whitelist: trueon all endpoints - [ ] Rate limiting on both application and nginx level
- [ ] Helmet with restrictive CSP — no wildcard inline scripts
- [ ] Field-level encryption for sensitive database columns (AES-256-GCM)
- [ ] Append-only audit log stored separately from the main database
Frontend (React Router)
- [ ] Route-level authorization via ProtectedRoute wrappers
- [ ] JWT: refresh token as httpOnly cookie, access token in-memory only — never localStorage
- [ ] CSP enforced as HTTP header from the backend, not as meta tag
Messaging
- [ ] End-to-end encryption: server never sees message plaintext
- [ ] Ephemeral keys per session for forward secrecy
- [ ] WebSocket auth validated on handshake before upgrade completes
- [ ] Replay attack protection via timestamp + sequence number
Network
- [ ] TLS 1.3, HSTS with preload, Certificate Transparency monitoring
- [ ] WAF active in production with tuned ruleset
Operations
- [ ] Real-time monitoring with defined alert thresholds
- [ ] Written incident response playbook
- [ ] GDPR breach notification process documented and tested
- [ ] Quarterly tabletop exercises
Closing: Security as Culture, Not as Feature
Technical measures alone aren't enough. What truly protects is a culture where security is understood as a quality trait, not a brake on velocity. No developer on the team should find the question "is this secure?" annoying — it's part of the definition of done.
For platforms in the adult industry, this holds with particular force. Users entrust us with data that directly affects their lives. That trust is non-negotiable. And neither is the technical foundation that supports it.
We are the engineering team behind one bb-escort.de. On dev.to we share what we've learned scaling, securing, and building our platform — unvarnished and practical.
Have a question about any of the patterns described here? Drop it in the comments — we read everything.
Top comments (0)