Essential Security Best Practices for Web & API Applications

Protecting user authentication, JWT tokens, and REST API endpoints

TheWebHero Security Group

In this article
  1. 01Start with a threat model, not a tool
  2. 02Authentication done right
  3. 03Authorization and broken access control
  4. 04Validating input and preventing injection
  5. 05Hardening the HTTP layer
  6. 06Secrets, dependencies and logging
  7. 07A pre-launch security checklist

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 bcrypt or argon2 with 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, SameSite cookie cannot be read by JavaScript, which protects it from XSS. Avoid keeping tokens in localStorage.
  • 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:

js
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.
js
// 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 zod or joi check 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 dangerouslySetInnerHTML unless 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.
js
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 invalid

Hardening 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-Security header so browsers never downgrade.
  • Add security headers: the helmet middleware 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 .env files 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 audit in 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.

SecurityAPIWeb DevelopmentBackend

Related services:API Development

Have an idea? Let's build it the right way.

Get a clear plan, timeline and estimate within 24 hours.