Skip to content

Security basics ​

Auth chapters get you logged in. This chapter is the seatbelt - validation, headers, rate limits, and the JWT mistakes I keep seeing (including my own older code). 😅

Validate input with Zod ​

bash
npm install zod
ts
import { z } from 'zod'

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(100),
})

authRouter.post('/register', async (req, res, next) => {
  try {
    const body = registerSchema.parse(req.body)
    // body.email / body.password are typed + cleaned
    // …
  } catch (err) {
    if (err instanceof z.ZodError) {
      return next(new HttpError(400, err.issues[0]?.message ?? 'invalid input'))
    }
    next(err)
  }
})

FE map: same library you might use in forms - run it again on the server. Client validation is UX; server validation is security.

Helmet ​

bash
npm install helmet
ts
import helmet from 'helmet'
app.use(helmet())

Sets sane HTTP headers (XSS protections, etc.). Not a full security program - a cheap win.

Rate limit auth routes ​

bash
npm install express-rate-limit
ts
import rateLimit from 'express-rate-limit'

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 50, // tune for your threat model
  standardHeaders: true,
  legacyHeaders: false,
})

app.use('/api/auth', authLimiter)

Slows credential stuffing and reset spam. Put stricter limits on /login and /forgot if you want.

JWT footguns ​

  1. Weak JWT_SECRET - use a long random string; never commit it
  2. Storing sensitive data in the payload - anyone can base64-decode JWT claims; signature only proves integrity
  3. No expiry - always set expiresIn
  4. localStorage XSS - if JS can read the token, XSS can steal it; httpOnly cookies trade that for CSRF complexity
  5. Assuming logout revokes tokens - delete client-side; server still accepts until expiry unless you version/blocklist

For this tutorial's learning path, Bearer + localStorage is OK. For a bank app, read more. 🙂

Passwords ​

  • Min length (8+; prefer 12+ in products)
  • bcrypt / argon2 - not MD5, not SHA alone
  • Constant-ish error messages on login
  • Optional: breach checks (Have I Been Pwned k-anonymity API) later

SQL ​

Always bound parameters (? placeholders). If you ever write:

ts
db.prepare(`SELECT * FROM users WHERE email = '${email}'`) // NOPE

…stop. That's how SQLi demos get written.

CORS is not auth ​

Allowing origin: http://localhost:5173 does not authenticate users. CORS protects browsers from other websites reading responses. APIs still need requireAuth.

Quick hardening checklist ​

  • [ ] Zod (or similar) on auth bodies
  • [ ] helmet()
  • [ ] Rate limit /api/auth
  • [ ] Strong JWT_SECRET
  • [ ] HTTPS in production
  • [ ] .env gitignored
  • [ ] No password hashes in JSON responses

You don't need a CISO office to ship a side project - but skipping all of the above is how side projects become incident writeups.

Next: where to go after Express feels comfortable.

Best,

-- ł.

Notes-style Express tutorial for front-end folks