Security
Multi-tenant isolation, CSP nonce, rate limiting, encryption, IDOR protection.
Contents
- Overview
- Multi-tenant isolation
- IDOR protection
- Content Security Policy (CSP)
- Rate limiting
- Token encryption
- Security headers
- Webhook validation (HMAC)
- CVE-2025-29927 protection
- Maintenance mode
- Audit trail
- tRPC error handling
- Security checklist
Overview
HeartCo implements defense in depth with several layers of protection:
- Multi-tenant isolation — Each organization only sees its own data
- RBAC — Granular per-role permissions
- CSP — Content Security Policy with a per-request nonce
- Rate limiting — Brute-force protection
- Encryption — OAuth tokens encrypted at rest
- Security headers — HSTS, X-Frame-Options, etc.
- HMAC — Webhook validation
Multi-tenant isolation
Principle
Every piece of data belongs to an organization through the organizationId field. Access is controlled at two levels:
- Reads:
ctx.orgDbautomatically injectsorganizationIdinto queries - Writes:
organizationIdmust be explicitly added to everyupdate/delete
Mandatory rules
// ✅ MANDATORY — organizationId in the where for update/delete
await ctx.db.invoice.update({
where: { id: input.id, organizationId: ctx.session.user.organizationId },
data: { status: "PAID" },
});
// ❌ IDOR VULNERABILITY — no organizationId
await ctx.db.invoice.update({
where: { id: input.id },
data: { status: "PAID" },
});Sub-entities
For models without a direct organizationId (invoice lines, etc.), filter through the parent:
const line = await ctx.db.invoiceLine.findFirst({
where: {
id: input.lineId,
invoice: { organizationId: ctx.session.user.organizationId },
},
});findFirst vs findUnique
Always use findFirst when filtering by organizationId + id:
// ✅ findFirst — supports composite filters
await ctx.orgDb.invoice.findFirst({ where: { id: input.id } });
// ❌ findUnique — does NOT filter by organizationId
await ctx.db.invoice.findUnique({ where: { id: input.id } });File: src/lib/prisma-org-scope.ts — 125 auto-scoped models.
IDOR protection
Access errors must return NOT_FOUND (not FORBIDDEN) to avoid leaking information:
const invoice = await ctx.orgDb.invoice.findFirst({
where: { id: input.id },
});
if (!invoice) {
// ✅ NOT_FOUND — the attacker can't tell whether the resource exists
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
}
// ❌ FORBIDDEN — reveals that the resource exists but belongs to someone else
// throw new TRPCError({ code: "FORBIDDEN" });assertOwnership
For resources with createdById, use the helper:
import { assertOwnership } from "~/server/api/trpc";
// Checks that ctx.session.user.id === resource.createdById
// Automatic bypass for roles ≥ MANAGER
assertOwnership(ctx, resource.createdById, "MANAGER");Content Security Policy (CSP)
File: src/middleware.ts
A unique nonce is generated for each request via crypto.randomUUID():
const nonce = crypto.randomUUID();
// Injected into the CSP header and available to inline scriptsCSP directives
| Directive | Value |
|---|---|
default-src | 'self' |
script-src | 'self' + nonce (no unsafe-inline) |
style-src | 'self' 'unsafe-inline' (required by Tailwind v4) |
img-src | 'self' data: https: blob: |
connect-src | Stripe, Supabase, Pusher, Sentry, Mistral AI |
frame-src | Stripe (checkout iframe) |
worker-src | 'self' blob: |
In development,
unsafe-evalis added for webpack hot module replacement (HMR).
Rate limiting
File: src/lib/rate-limit.ts
Backend: Upstash Redis
Distributed rate limiting with a sliding window:
import { checkRateLimit } from "~/lib/rate-limit";
// 10 requests max per IP in 15 minutes
const allowed = await checkRateLimit(
`login:${ip}`,
10,
15 * 60 * 1000
);In-memory fallback
If Redis is not configured (local development), an in-memory counter is used.
Rate-limiting points
| Endpoint | Limit | Window |
|---|---|---|
| Login (Credentials) | 10 requests/IP | 15 minutes |
| Chat (Chatbot) | 10 messages/user | 1 minute |
| Demo mode | Variable/user | 1 hour |
Token encryption
Variable: TOKEN_ENCRYPTION_KEY (64 hex characters = 32 bytes)
OAuth tokens (Google, Microsoft) are encrypted with AES-256-GCM before being stored in the database:
// Encryption
const encrypted = encryptToken(googleAccessToken);
await db.emailAccount.update({ data: { accessToken: encrypted } });
// Decryption (automatic on refresh)
const decrypted = decryptToken(emailAccount.accessToken);Generate a key:
openssl rand -hex 32
Security headers
File: next.config.js
| Header | Value | Protection |
|---|---|---|
X-Frame-Options | DENY | Clickjacking |
X-Content-Type-Options | nosniff | MIME sniffing |
X-XSS-Protection | 0 | Disabled (CSP is enough) |
Referrer-Policy | strict-origin-when-cross-origin | Referrer leakage |
Permissions-Policy | camera, microphone, geolocation disabled | Browser APIs |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Forced HTTPS (2 years) |
Webhook validation (HMAC)
Incoming webhooks must be validated with HMAC-SHA256 and timingSafeEqual:
import crypto from "crypto";
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const computed = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// ✅ timingSafeEqual — resistant to timing attacks
return crypto.timingSafeEqual(
Buffer.from(computed, "hex"),
Buffer.from(signature, "hex")
);
// ❌ FORBIDDEN — vulnerable to timing attacks
// return computed === signature;
}CVE-2025-29927 protection
File: src/middleware.ts
Protection against the Next.js middleware bypass via the x-middleware-subrequest header:
// Blocks requests carrying this header (returns 400)
if (request.headers.get("x-middleware-subrequest")) {
return new Response("Forbidden", { status: 400 });
}Maintenance mode
Variable: MAINTENANCE_MODE=true
When enabled:
- All pages redirect to
/maintenance - Exceptions: Stripe/Iopole webhooks, health API, crons, static assets
- Bypass: query param or
maintenance-bypasscookie withMAINTENANCE_BYPASS_SECRET
Audit trail
The AuditLog model records sensitive actions:
- Who (userId, role)
- What (action, resource, resourceId)
- When (timestamp)
- Context (IP, user agent)
Permission denials are automatically logged by the requirePermission() middleware.
tRPC error handling
Always use TRPCError, never throw new Error():
| Code | Usage |
|---|---|
UNAUTHORIZED | No session |
FORBIDDEN | Not allowed (missing permission) |
NOT_FOUND | Missing resource or IDOR |
BAD_REQUEST | Invalid input |
TOO_MANY_REQUESTS | Rate limit reached |
throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
});Security checklist
Before each merge, verify:
-
organizationIdin everyupdate/delete -
findFirst(notfindUnique) for scoped resources -
TRPCError(notthrow new Error) - NOT_FOUND for IDOR (not FORBIDDEN)
-
requirePermission()on internal routes -
timingSafeEqualfor webhooks - No
any— useunknown+ type guards - No secrets in the code (use env vars)