Skip to content

A Production Security Checklist

Aman Kumar Singh8 min read
Part 38 of 40From the Full Stack SaaS Masterclass series
A Production Security Checklist — article by Aman Kumar Singh

We spent the last article 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.

Authentication and session handling

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:

  • Passwords hashed with bcrypt or argon2, never anything you rolled yourself.
  • Access tokens short-lived (minutes, not days), refresh tokens rotated on use and stored server-side so you can revoke them.
  • Cookies for session tokens set httpOnly, secure, and sameSite: 'lax' or 'strict' depending on whether you need cross-site requests at all.
// 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.

Authorization and tenant isolation

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:

  1. Never trust a tenant ID that comes from the client. Derive it from the authenticated session, not a request body or query param.
  2. Enforce it at the data layer, not just in application logic, wherever your database supports it.
-- 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.

Input validation and injection prevention

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,
  }),
);
// 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 and configuration management

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.

// logger/redact.ts
export const REDACTED_KEYS = ['password', 'authorization', 'refreshToken', 'apiKey'];

export function redact(obj: Record<string, unknown>) {
  const clone = { ...obj };
  for (const key of REDACTED_KEYS) {
    if (key in clone) clone[key] = '[REDACTED]';
  }
  return clone;
}

Transport and infrastructure hardening

HTTPS 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."

Set security headers with something like helmet rather than hand-rolling them, and don't skip rate limiting on anything that touches auth or lets a caller trigger expensive work.

// main.ts
import helmet from 'helmet';
import { ThrottlerGuard } from '@nestjs/throttler';

app.use(helmet());
app.useGlobalGuards(new ThrottlerGuard());

Rate 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.

Dependency scanning is the least glamorous item on this list and the one teams skip most. npm audit in 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.

Logging, monitoring, and incident readiness

Security 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.

Structured 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.

None 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.

Key takeaways

  • Authentication is the foundation: short-lived access tokens, rotated refresh tokens, and `httpOnly` cookies close off the most common session-hijacking paths.
  • Tenant isolation belongs at the application layer first, with row-level security as a defense-in-depth backstop, not a replacement.
  • Global DTO validation with `whitelist` and `forbidNonWhitelisted` stops mass-assignment and malformed-payload bugs before they reach your services.
  • Secrets go into a managed store and get injected at runtime; they never live in git history or plaintext files on a deployed image.
  • Security headers, rate limiting, and dependency scanning are cheap to add and expensive to skip; add them before launch, not after an incident.
  • Detection matters as much as prevention: structured logs and a couple of well-chosen alerts turn an unnoticed breach into a five-minute response.

Frequently asked questions

What is the most important security control for a new SaaS product?

Authentication and tenant isolation, in that order. Get short-lived tokens, rotated refresh tokens, and server-side tenant scoping right before layering on anything else.

Do I need row-level security in Postgres if my application already checks tenant ID?

It'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.

Is storing a JWT in localStorage actually dangerous?

Yes, mainly because of XSS exposure. Any script that runs on your page, your own code or a compromised dependency, can read `localStorage` and exfiltrate the token. An `httpOnly` cookie isn't reachable from JavaScript, which removes that attack path entirely.

How often should refresh tokens rotate?

Rotate 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.

Do I need a WAF or can rate limiting and validation cover me?

For 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.

What's the single most common security mistake in early-stage SaaS apps?

A 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.

Related articles

Guards and Authorization — Aman Kumar Singh
Scaling a SaaS Beyond One Server — Aman Kumar Singh
Organization and Multi-Tenant Authentication — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.