Security
Security practices, controls, and measures applied across the Leadmetrics platform. Covers all layers: authentication, authorization, data, transport, application code, infrastructure, and supply chain.
Related: Auth | Multi-tenancy | Infrastructure | Governance
Principles
- Defence in depth — multiple independent controls at every layer; compromise of one does not collapse the system
- Least privilege — agents, users, and services receive the minimum permissions needed for their specific role
- Zero trust at API boundaries — every request re-validates identity and tenant context; no implicit trust between services
- Fail closed — when a permission check fails or a gate is uncertain, deny by default; never auto-proceed
- Auditability — every write action and every LLM call is logged with actor, timestamp, before/after state; logs are immutable
- No secrets in code — all credentials live in Doppler; no hardcoded keys, tokens, or passwords anywhere in the repository
Authentication Security
See Auth docs for the full authentication design. Security-relevant specifics:
Tokens
- Access tokens are short-lived (15 min web, 30 min mobile) and unsigned in-memory only — never written to localStorage or sessionStorage
- Refresh tokens use 256-bit random values — unguessable, opaque, stored server-side
- Token rotation on every refresh — reuse of a superseded token triggers full session termination for all devices
- RS256 asymmetric signing — private key never leaves the API service; public keys published via JWKS
Session Storage
| Client | Access token | Refresh token |
|---|---|---|
| Web | JavaScript memory | HttpOnly; Secure; SameSite=Strict cookie |
| Mobile | React Native memory | iOS Keychain / Android Keystore (hardware-backed) |
HttpOnly cookies prevent JavaScript from reading the refresh token — XSS cannot exfiltrate it. SameSite=Strict prevents CSRF from forging requests with the cookie.
Brute Force Protection
- Login endpoint: 5 failed attempts per (email + IP) within 15 minutes → 15-minute lockout
- 2FA verification: 3 failed attempts per challenge token → challenge invalidated, user must re-login
- Password reset: rate-limited to 3 requests per hour per email address
- All lockout events are written to the audit log
Password Security
- Minimum 12 characters, character complexity required
- Bcrypt hashing with cost factor 12 (≈250ms on current hardware — fast enough for UX, slow enough to resist offline cracking)
- Breach check via haveibeenpwned.com k-anonymity API at registration and password change
- Last 5 passwords cannot be reused
Authorization & Tenant Isolation
Multi-Layer Tenant Isolation
Tenant data is isolated at five independent layers — compromise of one does not expose cross-tenant data:
| Layer | Mechanism | What it prevents |
|---|---|---|
| Database | Prisma middleware appends WHERE tenant_id = :tenantId to every query | Direct DB query returning another tenant’s rows |
| ORM schema | tenantId is a non-nullable foreign key on every tenant-scoped table | Schema-level enforcement; inserts without tenantId fail at DB level |
| API middleware | JWT tenantId claim extracted and validated on every request; mismatches rejected before handler runs | Handler receiving a request for a different tenant |
| Queue | Shared BullMQ queues agent__{agentRole}; worker verifies tenantId in job payload before execution | Agent job processed with wrong tenant’s data |
| Skills/RAG | MongoDB documents carry tenantId; all queries include { tenantId } filter | Skills or knowledge base content leaking across tenants |
Agent Permission Layers
Agents are further constrained beyond tenant isolation:
- Tool whitelist —
agent_configs.tool_names[]; the tool dispatcher rejects calls to any unlisted tool - Write-action approval gate — any tool call that publishes, sends, or spends requires a resolved
approvalsrecord - HITL mandatory — all deliverables pass through human approval; agents cannot bypass the approval gate
- Budget halt — activity, campaign, and tenant spend caps enforced before every agent dispatch; exceeded caps cancel the job
- Output validators — character limits, brand voice scoring, banned words filter run after agent output, before any write action
Role-Based Access Control
| Role | Can read | Can write | Can approve | Can configure |
|---|---|---|---|---|
member | Own tenant data | Submit campaigns, content requests | ❌ | ❌ |
admin | Own tenant data | All tenant actions | ✅ | Agent configs, team |
reviewer | Assigned tenants | Approvals, interventions | ✅ | ❌ |
super_admin | All tenants | All platform actions | ✅ | Global platform config |
Transport Security
- TLS 1.2+ enforced everywhere — HTTP requests are rejected (Traefik redirects to HTTPS)
- HSTS with
max-age=31536000; includeSubDomains; preload— clients remember to use HTTPS for 1 year - TLS 1.0 and 1.1 disabled on all endpoints
- Certificate management — Traefik (bundled with Coolify) handles Let’s Encrypt certificates automatically, with auto-renewal
- Internal service communication — service-to-service calls within the Docker network use a shared API secret; not directly internet-exposed
HTTP Security Headers
Applied by Traefik globally and by Next.js per-app:
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Enforce HTTPS |
Content-Security-Policy | See below | Prevent XSS and data injection |
X-Content-Type-Options | nosniff | Prevent MIME-type sniffing |
X-Frame-Options | DENY | Prevent clickjacking |
Referrer-Policy | strict-origin-when-cross-origin | Limit referrer leakage |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disable unused browser features |
Cross-Origin-Opener-Policy | same-origin | Isolate browsing context |
Cross-Origin-Resource-Policy | same-origin | Prevent cross-origin reads |
Content Security Policy (Dashboard/DM Portal):
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.leadmetrics.io;
font-src 'self';
frame-ancestors 'none';unsafe-inline on style-src is required for Tailwind CSS. No unsafe-eval anywhere.
Input Validation & Injection Prevention
SQL Injection
- Prisma ORM uses parameterised queries exclusively — no string concatenation into SQL
- Raw SQL is banned in application code (enforced by ESLint rule
no-raw-sql) - Database users have minimum required privileges — the app DB user has no
DROP,CREATE, orTRUNCATEpermissions
NoSQL Injection (MongoDB)
- Mongoose schemas enforce strict type validation —
$where,$regex, and operator injection in user-supplied fields are blocked by schema coercion - User-supplied filter values are validated with Zod before reaching Mongoose queries
XSS
- All user-supplied content rendered in React is escaped by default (JSX auto-escaping)
dangerouslySetInnerHTMLis forbidden (ESLint rule) except in one location: the deliverable preview pane, which sanitises HTML with DOMPurify before rendering- TipTap rich text editor output is sanitised with DOMPurify on both write (before storage) and read (before render)
- CSP
script-src 'self'blocks inline scripts injected via XSS
Command Injection
- The
ClaudeAdapterpasses task prompts as the-pflag argument — prompts are never interpolated into shell strings using template literals; they are passed as discrete argv elements tochild_process.spawn - Playwright (browser automation) runs in a sandboxed context with no shell access
Path Traversal
- File upload paths (skill files, report PDFs) are resolved through
path.resolve+ allowlist validation — paths outside the designated upload directory are rejected - Skill file names are sanitised (alphanumeric + hyphens only) before temp directory creation
Prompt Injection (LLM-specific)
Adversarial content in user-supplied inputs (brand guides, campaign briefs, scraped content) could attempt to hijack agent behaviour:
- System prompt boundary — agent instructions are placed in the system prompt; user content is in the user turn with explicit framing (
---BEGIN USER CONTENT---) - Input sanitisation — HTML and Markdown formatting stripped from scraped external content before injection into agent context
- Output validation — agent output is validated against expected format before any write action; unexpected instructions in output (e.g. “ignore previous instructions”) do not trigger tool calls
- Tool call schema validation — all tool call inputs are validated against strict Zod schemas; malformed inputs from the LLM are rejected, not executed
Data Security
Encryption at Rest
| Data | Encryption |
|---|---|
| PostgreSQL database | AES-256 at volume level (managed by cloud provider / disk encryption) |
| MongoDB database | AES-256 at volume level |
| S3 / DO Spaces blobs | AES-256 server-side encryption (SSE-S3 or SSE-KMS) — enabled at bucket level |
| Channel OAuth tokens | AES-256-GCM at application level (in channels.access_token, channels.refresh_token) — double-encrypted |
| Agent API keys | Bcrypt-hashed before storage |
| LLM API keys (per LLM provider config) | AES-256-GCM at application level |
| Tenant secrets (per-agent env vars) | AES-256-GCM, key derived per tenant from master encryption key |
| User passwords | Bcrypt (cost 12) |
Application-level encryption uses keys injected via Doppler (ENCRYPTION_KEY). The master encryption key is never stored in the database.
Object Storage (S3) Security
Bucket configuration:
- All buckets are private — no public access, no public ACLs.
BlockPublicAcls,BlockPublicPolicy,IgnorePublicAcls,RestrictPublicBucketsall enabled. - Server-side encryption enabled at bucket level (SSE-S3 minimum; SSE-KMS for production)
- Versioning enabled on the
{tenantId}/contracts/prefix to support Object Lock - Object Lock (WORM) on the
{tenantId}/contracts/prefix — legal records cannot be overwritten or deleted
Access control:
- The API service uses a dedicated IAM role/user with minimum permissions:
s3:PutObject,s3:GetObject,s3:DeleteObjecton the application bucket only - No
s3:ListBucketon the root bucket — the API constructs keys deterministically and never enumerates - Admin operations (bucket policy changes, enabling Object Lock) require a separate privileged IAM role not held by the application
Pre-signed URLs:
- All content is served via pre-signed URLs with a 15-minute expiry — the API never proxies S3 content
- Pre-signed URLs are generated server-side using the tenant’s session identity — a user from tenant A cannot obtain a URL for tenant B’s content
- URL contains the full S3 key including
{tenantId}/prefix — cross-tenant access is impossible without a valid pre-signed URL for that tenant
Tenant isolation in S3:
- All S3 keys are prefixed with
{tenantId}/— tenant data is logically isolated within the bucket - For Enterprise on-prem, each tenant can be assigned their own MinIO bucket for complete storage isolation
Local dev / on-prem (MinIO):
- MinIO is configured with
anonymous: none— no unauthenticated access - MinIO credentials are in
.env.local(local only, never committed) and in Doppler for staging/production - On-prem MinIO deployments follow the same bucket policy and encryption standards as production S3
Encryption in Transit
- All external traffic: TLS 1.2+
- Internal service-to-service (Docker network): unencrypted on the private network but protected by network-level isolation; services are not internet-accessible
- Database connections: TLS enabled for production PostgreSQL and MongoDB; certificate validation enforced
Data Minimisation
- LLM call logs store
promptHashandresponseHash(SHA-256) — not the full prompt or response text - Tool call logs store sanitised payloads — credentials and PII fields are redacted before storage
- Analytics data is aggregated; raw user behaviour events are not retained beyond 90 days
PII Handling
- Email addresses and user names are stored in the
userstable only — never duplicated into logs or audit trails - Lead data (CRM) is tenant-scoped and never included in cross-tenant analytics
- Audit log
before/afterdiffs redact fields annotated@piiin the Prisma schema (custom extension)
Secrets Management
All secrets are managed by Doppler. No secrets exist as environment variables in CI/CD pipelines, Dockerfiles, or .env files in the repository.
Secret rotation policy
| Secret | Rotation frequency | Automated |
|---|---|---|
| JWT signing keys (RSA) | Every 90 days | Manual (operator-triggered via Admin app) |
| API keys (Anthropic, OpenAI, SendGrid, etc.) | On compromise or every 180 days | Manual |
| Channel OAuth tokens (per tenant) | Automatic re-auth when expired | Automatic via OAuth refresh |
| Database passwords | Every 90 days | Via Doppler secret rotation |
| Tenant agent env secrets | On operator request | Manual via Manage app |
Repository secret scanning
- GitHub Advanced Security — secret scanning enabled; any committed secret triggers an immediate alert and CI failure
- truffleHog — runs in CI on every PR to catch secrets in diff
Infrastructure Security
Network
- All services run in a private Docker network — only Traefik (reverse proxy) is internet-facing on ports 80/443
- Database ports (5432, 27017, 6379, 6333) are not exposed outside the Docker network
- Ollama (11434) is not exposed outside the Docker network
Container Security
- All Docker images use pinned tags (not
latestin production) — prevents silent upstream changes - Non-root users inside all containers — services do not run as root
- Read-only root filesystems where possible
- No privileged containers
Dependency Security
- Dependabot — automated PRs for dependency updates with CVE annotations
pnpm audit— runs in CI; high and critical CVEs fail the build- License compliance —
license-checkerenforces no GPL-licensed packages in production code
Audit Logging
Every state change is recorded in the audit_logs MongoDB collection:
interface AuditLogEntry {
tenantId: string;
actorType: 'human' | 'agent' | 'system';
actorId: string;
action: string; // e.g. 'approval.resolved', 'campaign.created'
resourceType: string;
resourceId: string;
before?: unknown; // previous state (PII fields redacted)
after?: unknown; // new state
ipAddress?: string; // human requests only
userAgent?: string;
createdOn: Date;
}Properties of the audit log:
- Append-only — no updates or deletes (MongoDB collection with
{ validationLevel: 'strict' }) - Immutable documents — application DB user has
insertonly onaudit_logs; noupdateordelete - Retained for 2 years minimum
- Queryable by tenants (their own log) via Dashboard, and by super-admins for all tenants via Manage app
Vulnerability Management
Responsible Disclosure
- Security issues: reported to
security@leadmetrics.io - Response SLA: acknowledgement within 48 hours, initial assessment within 7 days
- CVSSv3 scoring used to prioritise: Critical/High fixed within 7 days, Medium within 30 days, Low within 90 days
Penetration Testing
- Annual third-party penetration test covering OWASP Top 10
- Scope: all three web apps, Fastify API, agent callback endpoints, OAuth server
- Results and remediation tracked in a private security repository
OWASP Top 10 Coverage
| Risk | Control |
|---|---|
| A01 Broken Access Control | Tenant isolation at 5 layers; role checks on every endpoint; fail-closed default |
| A02 Cryptographic Failures | TLS 1.2+ everywhere; AES-256 at rest; RS256 JWTs; bcrypt passwords |
| A03 Injection | Prisma ORM parameterised queries; Zod input validation; DOMPurify; no shell interpolation |
| A04 Insecure Design | Threat-modelled approval gates; no auto-proceed on expired approvals; agent budget halts |
| A05 Security Misconfiguration | Security headers; no default credentials; Dependabot; container hardening |
| A06 Vulnerable Components | Dependabot; pnpm audit in CI; license scanning |
| A07 Authentication Failures | Short-lived JWTs; token rotation; brute-force lockouts; 2FA enforcement for admin/reviewer |
| A08 Software and Data Integrity | truffleHog; pinned Docker image tags; signed commits enforced on main branch |
| A09 Logging & Monitoring Failures | Immutable audit log; OpenTelemetry tracing; Grafana alerting; Loki log aggregation |
| A10 SSRF | Agent tool URL allowlists; no user-controlled URLs in backend HTTP clients; Playwright runs sandboxed |
On-Prem Security Considerations
Enterprise on-prem deployments have additional responsibilities:
| Area | Responsibility |
|---|---|
| TLS certificates | Customer provides certificates or uses Let’s Encrypt via Coolify Traefik |
| Network isolation | Customer controls firewall rules; Leadmetrics recommends private subnet for DB services |
| Backup encryption | Customer responsible for encrypting database backups at rest |
| RSA key pairs | Customer generates and rotates JWT signing keys (see Sessions) |
| Secret management | Customer may use Doppler, Vault, AWS Secrets Manager, or any compatible secrets injector |
| OS patching | Customer responsible for host OS security updates |
| Air-gapped LLMs | Ollama-only mode available for full data sovereignty; no data sent to Anthropic/OpenAI |