{"slug": "a-production-security-checklist", "title": "A Production Security Checklist", "summary": "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.", "body_md": "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.\n\nThis 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.\n\nI'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.\n\nGet 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.\n\nA few non-negotiables for a NestJS backend:\n\n`httpOnly`\n\n, `secure`\n\n, and `sameSite: 'lax'`\n\nor `'strict'`\n\ndepending on whether you need cross-site requests at all.\n\n``` js\n// auth/cookie.config.ts\nexport const REFRESH_COOKIE_OPTIONS = {\n  httpOnly: true,\n  secure: process.env.NODE_ENV === 'production',\n  sameSite: 'lax' as const,\n  path: '/auth/refresh',\n  maxAge: 7 * 24 * 60 * 60 * 1000,\n};\n```\n\nThe 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.\n\nThe pitfall I see most often is storing the JWT in `localStorage`\n\nbecause 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`\n\ncookies aren't reachable from JavaScript, which removes that entire attack class for a small amount of extra plumbing on cross-origin requests.\n\nAuthentication 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.\n\nThe mistake that causes real damage is usually a query that forgot to filter by `organizationId`\n\n, 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.\n\nTwo things reduce this risk meaningfully:\n\n```\n-- Postgres row-level security as a backstop, not a replacement for app-layer checks\nALTER TABLE invoices ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY tenant_isolation ON invoices\n  USING (organization_id = current_setting('app.current_org_id')::uuid);\n```\n\nRow-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.\n\nEvery 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.\n\nNestJS 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.\n\n```\n// main.ts\napp.useGlobalPipes(\n  new ValidationPipe({\n    whitelist: true,\n    forbidNonWhitelisted: true,\n    transform: true,\n  }),\n);\njs\n// dto/create-invoice.dto.ts\nimport { IsUUID, IsNumber, Min, IsISO8601 } from 'class-validator';\n\nexport class CreateInvoiceDto {\n  @IsUUID()\n  customerId: string;\n\n  @IsNumber()\n  @Min(0)\n  amountCents: number;\n\n  @IsISO8601()\n  dueDate: string;\n}\n```\n\n`whitelist: true`\n\nstrips any property not declared on the DTO. `forbidNonWhitelisted: true`\n\nrejects 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' }`\n\nand an unguarded update handler happily writes it.\n\nSQL 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.\n\nSecrets in source control are the single most avoidable production incident I've seen, and they still happen constantly because a `.env`\n\nfile gets committed once and lives forever in git history.\n\nThe baseline: `.env`\n\nin `.gitignore`\n\nfrom 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.\n\n```\n# docker-compose.yml (local dev only, not how production gets secrets)\nservices:\n  api:\n    env_file:\n      - .env.local\n    environment:\n      - NODE_ENV=development\n```\n\nIn production, don't `env_file`\n\na 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.\n\nA subtler pitfall: logging. It's easy to log an entire request object for debugging and forget that the request includes an `Authorization`\n\nheader or a password field. Redact known-sensitive keys at the logger configuration level, not per call site, because per-call-site discipline eventually lapses.\n\n``` js\n// logger/redact.ts\nexport const REDACTED_KEYS = ['password', 'authorization', 'refreshToken', 'apiKey'];\n\nexport function redact(obj: Record<string, unknown>) {\n  const clone = { ...obj };\n  for (const key of REDACTED_KEYS) {\n    if (key in clone) clone[key] = '[REDACTED]';\n  }\n  return clone;\n}\n```\n\nHTTPS everywhere is table stakes, not a checkbox you argue about. What's less obvious is what sits between \"HTTPS is on\" and \"we're actually hardened.\"\n\nSet security headers with something like `helmet`\n\nrather than hand-rolling them, and don't skip rate limiting on anything that touches auth or lets a caller trigger expensive work.\n\n``` python\n// main.ts\nimport helmet from 'helmet';\nimport { ThrottlerGuard } from '@nestjs/throttler';\n\napp.use(helmet());\napp.useGlobalGuards(new ThrottlerGuard());\n```\n\nRate limiting deserves a second look specifically on login, password reset, and any endpoint that sends an email or SMS. Without it, your signup form becomes a free tool for someone to spam another person's inbox, and your login endpoint becomes a credential-stuffing target with no friction at all. Redis-backed rate limiting scales this across multiple instances, which matters the moment you're running more than one container.\n\nDependency scanning is the least glamorous item on this list and the one teams skip most. `npm audit`\n\nin CI, or a service like Snyk or GitHub's Dependabot, catches known vulnerabilities in the packages you already depend on. The tradeoff is noise: not every flagged vulnerability is exploitable in your context, and blindly bumping every dependency on every alert introduces its own risk of breakage. Triage the alerts; don't auto-merge them blind, and don't ignore them either.\n\nSecurity work isn't finished when the code ships. Most teams have a detection gap, not a prevention gap: something goes wrong and nobody notices until a customer complains.\n\nStructured logs with enough context to reconstruct an event (who, what tenant, what action, when) make the difference between a five-minute investigation and a two-day one. Pair that with alerting on the signals that actually matter: repeated failed logins from one account, a spike in 401/403 responses, an unusual volume of data exports.\n\nNone of this needs to be elaborate on day one. A log aggregator, a couple of alert rules, and a documented \"what do we do if this fires\" runbook cover most of the realistic scenarios a small SaaS team will face. Building a full SIEM setup before you have the traffic to justify it is complexity you haven't earned yet, in the same spirit as the stack decisions from the first article in this series.\n\n`httpOnly`\n\ncookies close off the most common session-hijacking paths.`whitelist`\n\nand `forbidNonWhitelisted`\n\nstops mass-assignment and malformed-payload bugs before they reach your services.**What is the most important security control for a new SaaS product?**\n\nAuthentication and tenant isolation, in that order. Get short-lived tokens, rotated refresh tokens, and server-side tenant scoping right before layering on anything else.\n\n**Do I need row-level security in Postgres if my application already checks tenant ID?**\n\nIt's a strong defense-in-depth layer rather than a strict requirement. Application checks stay your primary guard, but RLS means a bug in a service method doesn't automatically become a cross-tenant data leak.\n\n**Is storing a JWT in localStorage actually dangerous?**\n\nYes, mainly because of XSS exposure. Any script that runs on your page, your own code or a compromised dependency, can read `localStorage`\n\nand exfiltrate the token. An `httpOnly`\n\ncookie isn't reachable from JavaScript, which removes that attack path entirely.\n\n**How often should refresh tokens rotate?**\n\nRotate on every use, and track token families so a stolen refresh token that gets reused after rotation is detectable and revocable. Rotation on use matters more than the exact expiry window you pick.\n\n**Do I need a WAF or can rate limiting and validation cover me?**\n\nFor most small-to-mid SaaS products, solid input validation, rate limiting, and security headers cover the realistic threat surface. A WAF helps at higher traffic, but it isn't a substitute for fixing validation gaps in your own code.\n\n**What's the single most common security mistake in early-stage SaaS apps?**\n\nA query that forgets to filter by tenant ID. It rarely comes from a missing role check; it comes from an otherwise correct-looking query written without the tenant scope in mind, and it usually surfaces through a support ticket rather than a security scan.\n\nHi, I'm **Aman Singh** — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.\n\nI write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.", "url": "https://wpnews.pro/news/a-production-security-checklist", "canonical_source": "https://dev.to/moose978/a-production-security-checklist-3k0a", "published_at": "2026-07-24 02:00:18+00:00", "updated_at": "2026-07-24 02:31:31.068853+00:00", "lang": "en", "topics": ["developer-tools", "ai-safety"], "entities": ["NestJS", "Postgres", "OWASP"], "alternates": {"html": "https://wpnews.pro/news/a-production-security-checklist", "markdown": "https://wpnews.pro/news/a-production-security-checklist.md", "text": "https://wpnews.pro/news/a-production-security-checklist.txt", "jsonld": "https://wpnews.pro/news/a-production-security-checklist.jsonld"}}