In this article
Start with a threat model, not a tool
Security problems in web applications are rarely exotic. Most breaches come from a small set of mistakes: a missing permission check, an unvalidated input, a leaked secret, an outdated dependency. The OWASP Top 10 lists them every few years, and broken access control has sat at the top of the list since 2021.
Before adding tools, spend an hour on a simple threat model. List what the application stores that someone would want (customer data, payment references, admin access), who might want it, and how they could reach it. That list tells you where to focus, and it usually points at the same places this article covers.
Security is also cheaper when it is built in. Fixing an access-control flaw during development takes minutes. Fixing it after a data leak takes weeks and costs customer trust.
Authentication done right
Authentication answers "who is this user?" It is also where many teams roll their own solution and get the details wrong.
- Hash passwords properly: use
bcryptorargon2with a sensible cost factor. Never store plain text or use fast hashes like MD5 or SHA-256 for passwords. - Keep access tokens short-lived: JWT access tokens should expire in minutes, not days. Use a separate refresh token to get a new one.
- Rotate refresh tokens: issue a new refresh token on every use and invalidate the old one, so a stolen token stops working quickly.
- Store tokens in secure cookies on the web: an
HttpOnly,Secure,SameSitecookie cannot be read by JavaScript, which protects it from XSS. Avoid keeping tokens inlocalStorage. - Rate-limit login: slow down repeated attempts on login, signup and password-reset endpoints to stop credential stuffing.
- Offer multi-factor authentication: at minimum for admin accounts.
Setting a secure session cookie in Express looks like this:
res.cookie('refresh_token', token, {
httpOnly: true, // not readable from JavaScript
secure: true, // only sent over HTTPS
sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});Authorization and broken access control
Authorization answers a different question: "is this user allowed to do this, to this record?" Most real breaches happen here, because the check is easy to forget on one endpoint out of a hundred.
The classic example is an insecure direct object reference. A user requests /api/orders/1042, changes the number to 1043, and sees someone else's order because the API only checked that they were logged in.
- Check ownership on every request: always filter by the current user, for example find the order by both its ID and the user ID.
- Deny by default: new routes should require authentication and a role unless explicitly made public.
- Centralise permission logic: put role and ownership checks in middleware or a policy layer, not scattered through controllers.
- Never trust the client: hiding an admin button in the UI is not security. The API must enforce the rule.
// Only return the order if it belongs to the signed-in user
const order = await Order.findOne({
_id: req.params.id,
userId: req.user.id,
});
if (!order) return res.status(404).json({ error: 'Not found' });Returning 404 rather than 403 for records the user does not own also avoids confirming that the record exists.
Validating input and preventing injection
Every value that reaches the server from outside, whether body, query string, headers or uploaded files, is untrusted. Validate it against a schema at the edge of the API, before it touches business logic or the database.
- Validate with a schema: libraries like
zodorjoicheck types, lengths and formats, and reject anything unexpected. - Use parameterised queries: in SQL databases, never build queries by joining strings. Use placeholders through your driver or ORM.
- Guard against NoSQL injection: in MongoDB, an attacker can send an object like
{"$gt": ""}instead of a string. Schema validation that insists on strings blocks this. - Escape output: frameworks like React escape text by default. Avoid
dangerouslySetInnerHTMLunless the HTML has been sanitised. - Limit uploads: restrict file types and sizes, rename stored files, and never serve uploads from the same origin as the app without checks.
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(128),
});
const body = LoginSchema.parse(req.body); // throws if invalidHardening the HTTP layer
A few headers and settings close entire classes of attacks with very little effort.
- Enforce HTTPS everywhere: redirect HTTP to HTTPS and send a
Strict-Transport-Securityheader so browsers never downgrade. - Add security headers: the
helmetmiddleware for Express sets sensible defaults, including protections against clickjacking and MIME sniffing. - Write a Content Security Policy: a CSP limits which scripts can run, which greatly reduces the impact of XSS.
- Configure CORS with an allowlist: list the exact origins that may call the API. Never combine a wildcard origin with credentials.
- Rate-limit the whole API: a global limit plus stricter limits on sensitive routes protects against abuse and scraping.
- Hide stack traces: production errors should return a generic message and log the details server-side.
Secrets, dependencies and logging
Some of the most damaging incidents come from outside your own code.
- Keep secrets out of the repository: load API keys and database passwords from environment variables or a secrets manager, and add
.envfiles to.gitignore. - Rotate secrets: if a key might have been exposed, rotate it immediately. Prefer keys scoped to the minimum permissions they need.
- Audit dependencies: run
npm auditin CI, enable automated dependency updates, and remove packages you no longer use. - Log security events: record logins, failed logins, permission denials and password changes, without logging passwords, tokens or full card numbers.
- Alert on anomalies: a spike in failed logins or 403 responses is often the first sign of an attack.
- Back up and test restores: backups only count if you have restored from one recently.
A pre-launch security checklist
Before an application goes live, walk through this list:
- Passwords are hashed with bcrypt or argon2, and login endpoints are rate-limited.
- Tokens are short-lived, refresh tokens rotate, and web tokens live in HttpOnly cookies.
- Every endpoint checks authentication, role and record ownership, with deny-by-default routing.
- All input is validated with a schema, and database queries are parameterised.
- HTTPS, HSTS, helmet, CSP and a strict CORS allowlist are in place.
- Secrets are outside the codebase, and dependencies have no known critical vulnerabilities.
- Security events are logged and someone is alerted when something looks wrong.
None of these steps is complicated on its own. Applied together and from the start, they protect users, keep data private, and stop the most common attacks long before they become incidents.
Related services:API Development
