Securing a web application: a checklist
A practical, ordered security checklist for web apps: auth, sessions, input validation, headers, dependencies, and the boring stuff that actually stops breaches.

Most web app breaches are not clever. They are a leaked API key in a public repo, a missing authorization check on an admin route, or a dependency that went unpatched for a year. Securing a web application is mostly about doing a handful of unglamorous things consistently, not about buying a fancy product. Here is the checklist we actually run through on client projects, roughly in the order it matters.
Start with authentication you did not write
If you are hand-rolling password hashing, session cookies, and password resets in 2025, stop. Use a vetted library or a managed provider (NextAuth/Auth.js, Lucia, Clerk, WorkOS, whatever fits). The interesting bugs live in the boring edges: timing-safe token comparison, secure reset flows, account enumeration on the login form.
If you do store passwords yourself, the only acceptable answer is a slow, salted hash. Use argon2id or bcrypt, never SHA-256 or anything you wrote.
import argon2 from "argon2";
export async function hashPassword(plain: string): Promise<string> {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB, OWASP minimum
timeCost: 2,
parallelism: 1,
});
}
export async function verifyPassword(hash: string, plain: string) {
// argon2.verify is constant-time and reads the salt/params from the hash
return argon2.verify(hash, plain);
}Sessions and cookies
Session cookies need three flags, no exceptions: HttpOnly (JavaScript cannot read it, so XSS cannot steal it), Secure (HTTPS only), and SameSite. Use SameSite=Lax for most apps; it blocks the common CSRF cases while keeping top-level navigation working.
res.cookie("session", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 24 * 7,
});Set a real expiry, rotate the session ID on login (to kill session fixation), and invalidate server-side on logout. A JWT you cannot revoke is a liability, so keep access tokens short-lived and back them with a refresh token you can actually kill.
Authorization is where the real bugs hide
Authentication answers "who are you." Authorization answers "are you allowed to touch this row." Broken object-level authorization (IDOR) is the single most common serious bug we find in real codebases. The pattern is always the same: an endpoint reads an ID from the request and trusts it.
// Broken: any logged-in user can read any invoice
const invoice = await db.invoice.findUnique({ where: { id: params.id } });
// Fixed: scope every query to the authenticated principal
const invoice = await db.invoice.findFirst({
where: { id: params.id, organizationId: session.user.orgId },
});Check ownership on every read, write, and delete. Do not rely on the UI hiding a button. Centralize the rule if you can, so a new endpoint cannot forget it.
Treat every input as hostile
SQL injection
Use parameterized queries. ORMs like Prisma and Drizzle do this for you, but the second you reach for raw SQL, the danger comes back. Never build a query with string concatenation.
// Dangerous: user input lands directly in the query string
db.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);
// Safe: parameterized, the driver escapes it
db.$queryRaw`SELECT * FROM users WHERE email = ${email}`;Cross-site scripting (XSS)
React escapes values by default, which prevents most XSS. The exceptions are dangerouslySetInnerHTML, rendering user-supplied URLs into href (watch for javascript:), and injecting data into inline scripts. If you must render user HTML (a rich-text comment, say), sanitize it server-side with a library like DOMPurify and an allowlist. Do not write your own regex sanitizer.
Validate the shape, not just the type
Validate request bodies at the boundary with a schema library (Zod, Valibot) and reject anything that does not match. This is not just a correctness nicety; it shrinks your attack surface by refusing unexpected fields and malformed payloads before they reach business logic.
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email().max(254),
displayName: z.string().min(1).max(80),
role: z.enum(["member", "admin"]), // never accept arbitrary role strings
});
const parsed = CreateUser.safeParse(await req.json());
if (!parsed.success) return Response.json({ error: "Invalid input" }, { status: 400 });Note the role enum. Mass-assignment bugs happen when you spread a parsed body straight into a database write and a user smuggles in role: "admin". Whitelist the fields you accept.
Security headers and HTTPS
Serve everything over HTTPS and set HSTS so browsers refuse to downgrade. Add a Content-Security-Policy to limit where scripts can load from, which turns many XSS bugs into non-events. In Next.js you can set these once in middleware or next.config.js.
// next.config.js (headers async function)
{
key: "Content-Security-Policy",
value: "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'",
}At minimum, ship: Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options: nosniff, and Referrer-Policy: strict-origin-when-cross-origin. frame-ancestors 'none' replaces the old X-Frame-Options and stops clickjacking.
Secrets, dependencies, and the supply chain
- Keep secrets out of the repo and out of the client bundle. In Next.js, anything prefixed
NEXT_PUBLIC_ships to the browser, so a secret with that prefix is a public secret. Audit for it. - Use a secret manager or your host's env config (Vercel, Doppler, AWS Secrets Manager). Rotate anything that has ever been committed, because it is already scraped.
- Run
npm auditand a tool like Dependabot or Renovate. Most real-world compromises ride in through an outdated transitive dependency, not a zero-day. - Pin and lock dependencies (
package-lock.jsoncommitted) so a malicious version cannot slip in on the next install.
Rate limiting and abuse
Put rate limits on anything that costs money or leaks information: login, password reset, signup, search, and any endpoint that triggers email or external API calls. A token bucket keyed on IP plus account is usually enough to stop credential stuffing and brute force. Upstash, a Redis counter, or your gateway's built-in limiter all work. Without this, your login page is a free password-guessing oracle.
Logging, errors, and the data you leak by accident
Return generic error messages to clients and keep the stack traces in your server logs. A 500 that dumps a database error tells an attacker your schema. Conversely, log enough to investigate an incident: authentication events, authorization failures, and admin actions, with a request ID to tie them together. Do not log passwords, tokens, or full credit card numbers; scrub them before they hit your log sink.
The quick checklist
Run this before you ship:
- Auth handled by a vetted library, passwords hashed with argon2id or bcrypt.
- Cookies are
HttpOnly,Secure,SameSite; sessions rotate on login and die on logout. - Every data access is scoped to the authenticated user or org.
- All input validated with a schema; raw SQL is parameterized; mass-assignment fields whitelisted.
- CSP and HSTS set; site is HTTPS-only.
- No secrets in the repo or
NEXT_PUBLIC_vars; dependencies audited and locked. - Rate limits on auth and expensive endpoints.
- Errors are generic to users, detailed in logs, with no sensitive data logged.
Takeaway
You do not need a security budget to cover the cases that actually get people breached. Pick a vetted auth library, scope every query to its owner, validate input at the boundary, lock your dependencies, and ship a few headers. Do those consistently and you have closed the doors that real attackers walk through. If you want a second pair of eyes on yours, that is the kind of boring, careful work we like.
