The Security Checklist I Should Have Written Down Three Years Ago
Admin User
Author
I remember the exact moment I realized I'd been building production systems completely backwards. It was 3 AM on a Tuesday, standing in a meeting with a client who'd just discovered that users from completely different organizations could see each other's invoices. Not through some sophisticated attack vector—just because I'd forgotten to filter by organizationId in a single database query. The kicker? I'd written the authentication layer perfectly. The whole security architecture failed not at the hardest layer, but at the most obvious one.
That incident taught me something that no security course ever did: security isn't about implementing the fanciest cryptography or catching every edge case in your validation pipeline. It's about having a mental checklist you run through before you call something "done," and actually sticking to it. Reading through a comprehensive production security checklist recently reminded me why I should've written mine down years ago.
Authentication Gets All The Attention, But It's Just The First Domino
I've spent way too long arguing about JWT storage in localStorage versus httpOnly cookies. Here's what I've learned: the cookie debate isn't really about security theater—it's about eliminating entire classes of attacks with one simple constraint. If I store my authentication token in localStorage, I'm betting that every single JavaScript file running on my domain, every npm package I depend on, every unescaped user string will never introduce an XSS vulnerability. That's not security; that's a hope-based architecture.
What actually matters more in my experience is the refresh token rotation pattern. It sounds complex until you've had to revoke sessions after a security incident and realized you can't because your refresh tokens live forever. Building the token family concept early—tracking which refresh tokens descended from which original token to detect reuse attacks—is one of those investments that feels heavy on day one and obvious in hindsight.
The NestJS implementation I use now keeps refresh tokens server-side and rotates them on every use. It adds a database table and some extra logic, but it means I can revoke an entire session chain instantly. That's a meaningful difference between "we've contained the breach" and "we have no idea how many sessions are still valid."
Authorization Is Where The Real Disasters Happen
Authentication answers "who are you?" Authorization answers "what can you touch?" In a multi-tenant SaaS, that second question gets a third dimension: which tenant's data can you access?
The hard truth I've accepted is that my application-layer authorization checks aren't enough. Not because they're poorly written, but because I'm human and I will eventually miss one. I will write a dashboard query six months from now, tired and under deadline, and forget to filter by organization. The question isn't if that happens—it's whether my database has a safety net when it does.
Row-level security in Postgres changed how I think about this. It's not a replacement for careful application design; it's insurance. You set the current tenant on the database session and enforce it at the table level. A missing application check becomes a minor bug instead of a data leak.
-- Set this on every connection from your app
SET app.current_org_id = '550e8400-e29b-41d4-a716-446655440000';
-- Then RLS enforces it automatically
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (organization_id = current_setting('app.current_org_id')::uuid);
The tradeoff is that you have to remember to set app.current_org_id on every connection. With connection pools, that's its own adventure. But I'd rather have one thing to be paranoid about than rely solely on my code being perfect.
Input Validation Isn't Sexy But It Prevents Disasters
NestJS global validation pipes with class-validator give me most of what I need. The key is actually using them—running whitelist: true to strip unexpected properties and forbidNonWhitelisted: true to reject them outright. I've seen that alone catch mass-assignment bugs where a user payload sneaks in an isAdmin: true field.
SQL injection is genuinely handled if you use an ORM with parameterized queries. The only way I've seen this fail is when someone writes raw SQL later, string-concatenating a value because they think it's faster than learning the ORM syntax. Code review needs to treat that as a blocking issue, not a nitpick.
What I'd Actually Change
The checklist approach works, but it needs to be your checklist, not a copied list. I care about different things than a payments API does. Run through what's bitten you in production, what keeps you up at night, and make that the thing you check before deployment.
Security isn't something you finish. It's a direction you keep pushing in, informed by what you've learned from your own failures.
Source: This post was inspired by "A Production Security Checklist" by Dev.to. Read the original article