Web App Pre-Launch Checklist
I’ve been keeping a pre-launch checklist for web apps, and it’s grown enough that I thought I’d share it. It’s a mix of lessons from my own mistakes and from things I’ve picked up along the way.
Note
This checklist isn’t exhaustive, it has biases, and some items won’t apply depending on your app’s requirements.
🔒 Authentication cookies
-
HttpOnlyis set- Helps mitigate XSS, though it’s not a foolproof.
-
SameSiteis set appropriately- Use
LaxorStrictfor CSRF defense. - If set to
Lax(the default in most major browsers), double-check that there are noGETendpoints performing state-changing actions.
- Use
-
Secureis set- Make sure cookies are only sent over HTTPS.
-
Domainis set deliberately- Broader
Domainsettings send cookies to subdomains, which can pose risks if any subdomain is vulnerable. - Prefer host-only cookies: omit
Domainto bind the cookie to the exact host (not sent to subdomains). - 💡 Tip: Prefix cookie names with
__Host-to enforce host-only scope (Secure, Path=/, no Domain, HTTPS only). See MDN.
- Broader
🔒 User input validation
- Validation is performed server-side, not just client-side
- URLs provided by users are validated properly
- Disallow unsafe protocols (such as
javascript:). - Make sure regex checks can’t be bypassed (e.g. via case variations, encoding, or partial matches).
- Disallow unsafe protocols (such as
- No raw HTML from users is rendered directly
- Don’t embed user-provided strings directly with
element.innerHTMLor React’sdangerouslySetInnerHTML. Sanitize or escape them before rendering.
- Don’t embed user-provided strings directly with
- No SQL injection vulnerabilities
- Reference: OWASP SQL Injection Prevention Cheat Sheet
- Handles in URLs are validated properly
- Example scenario: user URLs look like
https://example.com/<handle> - Disallow strings that collide with app routes
- Disallow strings starting with
_(some platforms reserve them, e.g., Google App Engine reserves/_ah) - Consider disallowing numeric-only strings (some frameworks treat
/404specially) - Maintain a reserved words list (
admin,contact, etc) (See: reserved-usernames)
- Example scenario: user URLs look like
🔒 Response headers
-
Strict-Transport-Securityis set- Tells the browser to always use HTTPS for your domain, which helps prevent downgrade attacks.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload -
CSP
frame-ancestorsis set- Stops other sites from embedding your pages in iframes, a common clickjacking trick.
Content-Security-Policy: frame-ancestors 'self' # e.g. Allow embed only on your site -
X-Content-Type-Options:
nosniffis set- Prevents the browser from guessing file types (MIME sniffing), which can cause unexpected behavior. See: MDN.
🔒 Other security checks
-
Recent login is required for especially sensitive actions
- To mitigate the impact of potential XSS or session hijacking, require re-authentication before critical operations (e.g. account deletion, email change).
-
Dynamic responses aren’t cached by CDN/KV unintentionally
- Prevents private or user-specific data from being cached and leaked to other users.
-
Public object storage listings are disabled
- In storage services like Amazon S3 or GCS, misconfigured permissions can expose file listings, allowing unauthorized users to browse and access stored objects.
- See: Securing GCS Buckets: disable directory listing!
-
Redirect targets are validated (no open redirects)
- Prevent
https://example.com/login?redirect_to=https://evil.examplefrom sending users tohttps://evil.example.
- Prevent
-
Authorization checks are enforced for all CRUD operations
- Only logged-in users with the right permissions can change data.
-
Every DELETE/UPDATE statement includes a safe WHERE clause
- e.g. you meant to update one user’s products, but ended up updating all products.
- In my case with Prisma, I disable
updateMany/deleteManyexcept for a few specific WHERE clauses using Extensions. - If you’re on Drizzle,
eslint-plugin-drizzleis a handy safeguard.
- In my case with Prisma, I disable
- e.g. you meant to update one user’s products, but ended up updating all products.
-
User input is not directly included in response headers
- Prevents header injection and tampering risks.
-
Raw server error messages are not exposed
- Stack traces and internals stay on the server side.
-
File uploads are validated for type, size, and filename
- Reduces the risk of malicious uploads or resource exhaustion.
-
Database backups are enabled
-
Object storage backups are enabled
-
2FA is enabled for all cloud accounts
-
Content Security Policy (CSP) is set up if needed
- It’s a solid defense. Still, in practice, most projects just don’t have the bandwidth to set it up.
📧 Email Delivery
- User input in emails can’t be abused for spam
- e.g. someone puts an ad in their username and it shows up in notification emails.
- Keep it in check with things like character limits, making user-generated parts clearly marked, or filtering content up front.
- Mass spamming triggered by user actions is prevented
- e.g. “follow” action shouldn’t trigger notification emails to thousands of users at once.
- Cap the number of notifications per user within a given time window to prevent abuse.
- SPF / DKIM / DMARC are set up
- Batch emails are deduplicated on retry
- Many cloud tasks/queues (e.g. AWS, GCP) use "at least once" delivery. Ensure idempotency to avoid duplicate sends.
- Unsubscribe from newsletters/marketing emails is possible without login
- Newsletters/marketing emails are configured with
List-Unsubscribe=One-Click- See: Gmail sender guidelines.
📈 SEO
- A
<title>is set on every page- Make sure each page has a descriptive and unique title tag.
- Canonical URLs are set on important pages
- So that search engines can recognize duplicate URLs as the same content.
- Error pages are returned with 4xx/5xx
- Or at least set as
noindex.
- Or at least set as
- Search result pages are set as
noindexor canonicalized- Alternatively, clearly indicate in both
<title>and<h1>that the page is a search results page (e.g.,Search results: [keyword]), so arbitrary query terms don’t get indexed as standalone topics. - e.g. if the page title of
/search?keyword=BuyVi**rais simplyBuyVi**ra, that term may end up being indexed.
- Alternatively, clearly indicate in both
- Site-wide
noindexis not left on by mistake- Often left in after staging, intended for removal before release but forgotten.
-
robots.txtis configured- Allow/disallow crawlers appropriately. Don't block resources (CSS/JS) Google needs to render the page.
- Meta descriptions are set on SEO-critical pages
- Add meta descriptions for pages important for search visibility (e.g., home, product pages). For UGC or less important pages, it’s sometimes better to skip rather than use poor descriptions.
- An XML sitemap is submitted for public pages
- Helpful for sites with many public pages. For smaller sites where crawlers can easily follow internal links, it's not necessary.
📱 Open Graph
- OG tags are set on pages likely to be shared
- Good to configure
og:title,og:description,og:url,og:image,twitter:card(for the card type on X)
- Good to configure
💰 Payments
-
Payment provider and app state remain consistent
- Even if a mismatch occurs, it should be detected and surfaced for review.
- e.g. a payment succeeds on Stripe but the DB update fails.
-
Duplicate charges are prevented
-
Account deletion does not cause inconsistencies in billing or app logic
-
Subscriptions are auto-canceled on account deletion or freeze
-
Refund/proration rules are clearly stated in the ToS and shown on the cancel page
-
An easy cancel path is provided
-
Receipts/invoices can be downloaded
- For Stripe, just add a Customer Portal link.
- Keep it visible even after users cancel. I often run into apps that hide it once you cancel and it’s pretty annoying.
❤️ Accessibility
- Follow The A11Y Project Checklist
🚀 Performance
- JS bundles are kept slim
- Make sure unnecessary modules aren’t included. Run a bundle analyzer before release to double-check.
- Static assets are served via CDN
- Frameworks like Next.js or Nuxt.js generate lots of JS and CSS files. These should be delivered from a CDN rather than the origin.
- Images are set to avoid layout shifts
- Use
aspect-ratioor width/height attributes on<img>elements.
- Use
- Images are not oversized
- e.g., avoid loading a 2 MB image that is displayed at only 400px wide.
- Ideally, use efficient image formats (like WebP or AVIF) and add
srcsetfor proper sizing.
- Basic SQL indexes are in place
- Indexes can be added later as data grows, but having essential ones from the start prevents performance issues.
💻 Cross-platform checks
-
The UI displays correctly on phone and tablet sizes
- I often see this go wrong. It’s worth a quick check.
-
Fonts look natural on all major operating systems
- If you’re using system fonts instead of web fonts, make sure they look natural on every OS.
-
The layout stays consistent with different scrollbar settings
- On macOS, Set
Show scroll barstoAlwaysand check. - Modals can shift the layout when scrollbars are visible. scrollbar-gutter can help.
- On macOS, Set
-
The UI handles long user inputs without breaking the layout
- e.g. long usernames or long pieces of content should not cause layout issues.
💡 Other Considerations
- Legal docs (Terms & Privacy) are present
- LocalStorage/non-HttpOnly cookies may expire after ~7 days
- iOS Safari may purge JS-set cookies and localStorage after ~7 days of inactivity due to ITP.
- See: Safari ITP - everything you need to know.
- No dependency on third-party cookies
- Server errors are monitored/alerted
- 404/5xx pages are user-friendly
- Include clear next actions (e.g., links to the home page or search).
- Favicon is set
- apple-touch-icon is set
- Analytics tools are set up (if needed)
- Personally, I avoid adding analytics to sites that don’t really need tracking.

