A Production Security Checklist A developer shared a production security checklist for multi-tenant SaaS apps, covering authentication with httpOnly cookies, authorization with row-level security, and input validation. The checklist emphasizes preventing cross-tenant data leaks and XSS attacks, with practical code examples from a NestJS backend. We spent the last article https://amanksingh.com/blog/caching-strategies-for-a-saas making the app fast. This one is about making sure it's still standing when someone tries to break it. That's the uncomfortable truth about security work: nobody notices it when it's done right, and everybody notices the one gap you missed. This is part of the Full Stack SaaS Masterclass , a series that builds a multi-tenant SaaS end to end. Security is a mindset that touches auth, the database, the API layer, and the infrastructure around all of it, not a single article's worth of work. What follows is the checklist I actually run through before calling something production-ready, with the reasoning behind each item so you can decide what applies to your own app. I'm resisting the urge to list forty things here. Most of the OWASP Top Ten is either handled by your framework already or irrelevant until you've earned the complexity that creates the risk. What follows is the subset that actually bites SaaS teams in practice. Get this wrong and nothing else matters. If someone can impersonate another user, your rate limiting and your input validation are decorating a house with an open front door. A few non-negotiables for a NestJS backend: httpOnly , secure , and sameSite: 'lax' or 'strict' depending on whether you need cross-site requests at all. js // auth/cookie.config.ts export const REFRESH COOKIE OPTIONS = { httpOnly: true, secure: process.env.NODE ENV === 'production', sameSite: 'lax' as const, path: '/auth/refresh', maxAge: 7 24 60 60 1000, }; The tradeoff people underestimate is refresh token rotation. It's more moving parts: you need a token family concept so that if a stolen refresh token gets reused after rotation, you can detect it and kill the whole session chain. It's worth building once, early, rather than bolting it on after an incident forces the issue. The pitfall I see most often is storing the JWT in localStorage because it's convenient for a single-page app. It reads fine in a demo. It also means any XSS vulnerability anywhere in your frontend, a compromised npm package, an unescaped user string, hands over every active session. httpOnly cookies aren't reachable from JavaScript, which removes that entire attack class for a small amount of extra plumbing on cross-origin requests. Authentication answers "who are you." Authorization answers "what are you allowed to touch," and in a multi-tenant SaaS, that second question has an extra dimension: which tenant do you belong to, and is every query you run scoped to it. The mistake that causes real damage is usually a query that forgot to filter by organizationId , not a missing role check. A user in tenant A ends up reading or writing rows that belong to tenant B. What catches it is rarely a broken permission check. Usually it's a support ticket asking why unrelated data showed up on someone's dashboard. Two things reduce this risk meaningfully: -- Postgres row-level security as a backstop, not a replacement for app-layer checks ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant isolation ON invoices USING organization id = current setting 'app.current org id' ::uuid ; Row-level security is a defense-in-depth layer, not a silver bullet. It adds a session variable you have to set correctly on every connection, and it's easy to forget in a connection pool if you're not deliberate about it. I still think it's worth having, because it means a bug in your NestJS service layer doesn't automatically become a cross-tenant data leak. The application-level check should be your primary guard; RLS is the seatbelt for when that check has a bug. Every string that enters your system from outside is untrusted until proven otherwise: request bodies, query params, headers, webhook payloads, file uploads, even values from a third-party API you call. NestJS gives you most of this for free if you actually use it. A global validation pipe with DTOs stops malformed and unexpected payloads before they reach a service. // main.ts app.useGlobalPipes new ValidationPipe { whitelist: true, forbidNonWhitelisted: true, transform: true, } , ; js // dto/create-invoice.dto.ts import { IsUUID, IsNumber, Min, IsISO8601 } from 'class-validator'; export class CreateInvoiceDto { @IsUUID customerId: string; @IsNumber @Min 0 amountCents: number; @IsISO8601 dueDate: string; } whitelist: true strips any property not declared on the DTO. forbidNonWhitelisted: true rejects the request outright instead of silently dropping the extra field. That combination has quietly saved me from mass-assignment-style bugs where a client sends { ...formData, role: 'admin' } and an unguarded update handler happily writes it. SQL injection is largely a non-issue if you use a query builder or ORM Prisma, TypeORM with parameterized queries, which you should be doing by default. The pitfall is the raw query someone writes six months later to work around an ORM limitation, string-concatenating a value into SQL because it was faster than learning the parameterized syntax. Ban string-concatenated SQL in code review, no exceptions, and treat it as a blocking comment rather than a nitpick. Secrets in source control are the single most avoidable production incident I've seen, and they still happen constantly because a .env file gets committed once and lives forever in git history. The baseline: .env in .gitignore from commit one, secrets injected at runtime through your deployment platform AWS Secrets Manager, SSM Parameter Store, or your CI/CD provider's secret store , and different credentials per environment so a compromised staging key can't touch production. docker-compose.yml local dev only, not how production gets secrets services: api: env file: - .env.local environment: - NODE ENV=development In production, don't env file a plaintext secrets file into a container image or repo. Pull secrets at deploy time from a managed store and inject them as environment variables into the running process, so the secret never touches disk on the image layer or a git commit. A subtler pitfall: logging. It's easy to log an entire request object for debugging and forget that the request includes an Authorization header or a password field. Redact known-sensitive keys at the logger configuration level, not per call site, because per-call-site discipline eventually lapses. js // logger/redact.ts export const REDACTED KEYS = 'password', 'authorization', 'refreshToken', 'apiKey' ; export function redact obj: Record