I want to do something this article that most pre-launch security checklists don't. I want to put the things teams actually do before launch next to the things they should be doing, in two columns, so you can see the gap.
The reason this format is useful is that almost every team thinks they have done a "security pass" before launch. They have, mostly. They have done a particular kind of security pass — the kind that catches the bugs nobody has ever shipped a successful exploit against. They have not done the other kind — the one that catches the bugs your product is actually going to ship with.
Below is the comparison. Read it like a confrontation. The left column is the version of pre-launch security that lets the company tell investors "we did a security review." The right column is the version that, two years later, lets the company tell its customers "we did not have the breach we could have had."
On authentication
| What most teams do | What we'd do |
|---|---|
| "We use bcrypt for passwords." | Argon2id with a documented work factor; passwords are paired with a server-side pepper, not just per-user salt; the team has tested what happens if the database leaks but the pepper does not (the answer should be "passwords stay safe"). |
| "We have MFA — you can enable TOTP in settings." | MFA is required for any sensitive action (email change, password change, billing, MFA enrollment itself), not just login; recovery codes do not bypass MFA; "trusted device" cookies expire on a real schedule. |
| "Password reset works — we send an email with a link." | Reset tokens are 32+ characters, expire in 15 minutes, are single-use, do not appear in URLs that get logged; resetting a password invalidates all existing sessions; the reset endpoint is rate-limited per IP and per target email. |
| "Account lockout is on — five wrong attempts and you're locked out." | Per-IP rate-limiting, not per-account lockout (per-account lockout is a free DoS attack against any user). Lockout signals are correlated across endpoints to detect distributed attempts. |
| "We require email verification before login." | The verification link is single-use, short-lived, and the email address cannot be changed without re-verification. The verification flow itself does not log the user in (preventing the link-click-as-CSRF pattern). |
The pattern here is that almost every team has "authentication" boxes ticked. The gap is the unglamorous details — what happens when MFA is added, when a password is reset, when a session sticks around, when an attacker tries to systematically guess. Those details are where the actual takeovers live.
On authorization
| What most teams do | What we'd do |
|---|---|
| "Every endpoint checks if you're logged in." | Every endpoint that touches user data checks if you're allowed to touch this specific user data. The check is at the data-access layer, not the controller layer, so new endpoints inherit the model. |
| "Our admin panel is on a separate subdomain." | The admin panel is on a separate subdomain, gated by an additional factor (VPN, IP allowlist, or hardware key), audited on every action, and the bugs in it have been specifically pressure-tested — not just the customer-facing UI. |
| "Multi-tenant — each org's data is isolated." | The isolation is enforced at the data layer. Every database query for an entity is scoped by tenant_id automatically, in code that's hard to bypass. We've tested this by trying to access another tenant's data with our own session. |
| "Some endpoints are internal — the frontend won't call them." | The endpoints either don't exist on production, or they require the same authorization as customer endpoints. "The frontend won't call it" stops being true the moment an attacker reads our JavaScript bundle, which they will. |
This row is the one most teams look at and feel uncomfortable. The "we check if you're logged in" version is what most code reviews catch. The "we check if you're allowed to touch this specific record" version is what production needs. The gap between them is where 58% of API breaches live — BOLA, broken object-level authorization, the most-disclosed API bug class of the last three years.
On input handling
| What most teams do | What we'd do |
|---|---|
| "We validate inputs on the frontend." | Server-side validation with explicit schemas (Zod, Joi, Pydantic, etc.). Frontend validation is UX. Server-side validation is security. The two are different. |
| "We use the framework's default ORM — it handles SQL injection." | The ORM handles SQL injection where the team uses it correctly. We've audited the raw-query paths, the search endpoints that use full-text search, and any code that builds queries from user-supplied column or table names. |
| "Mass assignment is prevented by our framework." | Mass assignment is prevented by an explicit allowlist on every endpoint that accepts an object update. We checked. We did not assume. |
| "File uploads are validated by extension." | File uploads are validated by content type sniffing, stored on a separate origin (so the browser won't execute them in the main app context), and renamed to prevent path-traversal tricks. We tested with files whose extension and content type disagree. |
| "We sanitize HTML to prevent XSS." | We use a maintained sanitizer (DOMPurify, sanitize-html) with strict settings, and we have a Content-Security-Policy that prevents inline script execution even if our sanitizer misses something. |
On data
| What most teams do | What we'd do |
|---|---|
| "We don't return passwords in API responses." | We have an explicit allowlist of fields per response type. The user object response does not include password hash, internal flags, audit fields, or VIP-customer metadata — because the API doesn't know what the UI will render, and the UI is not a security control. |
| "PII is encrypted in our database." | The team can point to which fields are encrypted, what algorithm, where the key lives, who has access to the key, and what the rotation schedule is. "Encrypted in the database" is a phrase. Verifying it is engineering. |
| "We back up the database daily." | We back up the database daily, we test the restore monthly, we have restored from a backup in the last 90 days, and the backups are stored somewhere a ransomware attacker cannot delete. |
| "We know what data we collect." | The team can produce, on demand, a complete list of every field collected, where it's stored, why it's collected, who has access to it, and how long it's retained. This list exists in writing. It's been audited against actual production behavior. |
On HTTP and infrastructure
| What most teams do | What we'd do |
|---|---|
| "We're on HTTPS." | We're on HTTPS, with HSTS set to at least a year, includeSubDomains, and preload. We've submitted the domain to the HSTS preload list. We've tested what happens if a user types http:// — it should never reach a plaintext connection. |
| "We have security headers." | We scanned ourselves on securityheaders.com or Mozilla Observatory and we have an A or A+ grade. We have a Content-Security-Policy that doesn't include unsafe-inline or unsafe-eval. |
| "We rate-limit our API." | We rate-limit every endpoint that authenticates, sends an email, costs money, or makes an outbound network call. The rate limits are tested under load. We can show the response from a brute-force test against our login endpoint. |
| "Errors are caught and logged." | Errors are caught and logged. Production errors do not return stack traces, file paths, database queries, or environment variable values in the response to the user. We've tested by sending malformed requests to see what comes back. |
On dependencies and supply chain
| What most teams do | What we'd do |
|---|---|
| "We use Dependabot." | We use Dependabot, we triage its PRs within a week, we have a documented SLA for security-CVE PRs (24-48 hours), and we have audited Dependabot's blind spots (it does not catch unsafe usage of safe libraries, or unpublished gadgets in dependencies). |
| "Every dependency is recent." | Every dependency was last updated within 90 days. We can produce the version-pin list on demand. The list does not include any library on a public CVE list we haven't acted on. |
| "We use environment variables for secrets." | Secrets are in a secrets manager (Vault, AWS Secrets Manager, Doppler), not environment variables (which leak in process dumps and crash reports). We've scanned the git history with a tool like gitleaks and the result is clean. We've scanned the build artifacts the same way. |
| "Our third-party scripts are from trusted vendors." | We can list every third-party script loaded by our frontend, explain why each is there, and produce the SRI (Subresource Integrity) hashes for those that support it. We have a plan for what happens if one of those vendors gets compromised. |
On the operational side
| What most teams do | What we'd do |
|---|---|
| "We log everything." | We log authentication events, authorization failures, admin actions, payment events, and access to sensitive endpoints. The logs are queryable. The team has done a fire drill where they queried for a specific suspicious pattern under a deadline. |
| "We have monitoring." | We have alerts for the security events that matter: failed login spikes, password resets at unusual volume, admin actions outside business hours, outbound calls to 169.254.169.254 from our app servers. The alerts are tested and route to a real on-call. |
| "We have an incident response plan." | We have a document. The team has rehearsed it. The rehearsal happened in the last 90 days. The document includes who calls legal, who calls the breach notification regulator, and what the customer communication template is. |
| "Security researchers can email us." | We have a security@ mailbox that's monitored, a /.well-known/security.txt file that says how to reach us, a published responsible-disclosure policy, and the last researcher to email us got a response within 48 hours. |
The pattern between the columns
Look across the two columns and you'll notice something. The right column is not a longer list. It's the same list, with the verification removed from "assumed" and added to "demonstrated."
The left column is what teams say when asked. The right column is what teams can show when pressed. The gap between them is, in our experience, the single most reliable predictor of which companies have a breach in the next eighteen months and which ones don't.
The point of the comparison is not to make you feel bad about your checklist. Most companies have most of the left column. Some have most of the right column. The right column requires more work, but the kind of work — verification, testing, documentation — that is much smaller in aggregate than rebuilding after a breach.
The honest gut check
Go through the two columns. For each row, ask: which column does my team actually live in? Not the column they'd put on a SOC 2 form. The column they can demonstrate, with logs and tests, this afternoon.
Most teams find they're at about 30% right column, 70% left column. That ratio is normal. It is also fixable. The work is to walk through each row, identify which gaps matter most for the team's threat model, and decide which to close before launch and which to accept as documented risk.
You don't need to be 100% right column to ship. You need to know where you actually are. Going to production with unknown gaps is the only failure mode that is avoidable for free.
If you'd rather have someone else walk through this comparison with you — and the fifty rows it doesn't include — that's what a pre-launch security review is for. The deliverable is the same comparison, applied specifically to your codebase, with the gaps and the fixes named precisely.
The comparison was the article. The decision is yours.