5+ years software engineer
5+ years software engineer
5+ years software engineer
5+ years software engineer
Securing an API is not one switch you flip — it's layers, each catching what the last one missed. This is defense in depth: HTTPS, authentication, authorization, rate limiting, validation, safe errors, logging, and secret management, stacked so that a single failure doesn't hand over the whole system. The industry reference for what actually goes wrong is the OWASP API Security Top 10, and most breaches I've seen trace back to one of its items — usually a missing authorization check. I've built and hardened APIs for e-commerce apps moving real money, and this is the checklist I run down every time.
This is a deep dive under my full-stack app guide. It pairs closely with how to authenticate users — auth is the layer, this is the whole wall.
Non-negotiable, first layer. Without TLS, every request — credentials, tokens, personal data — travels in plaintext that anyone on the network path can read. Serve only over HTTPS, redirect HTTP to HTTPS, and tell browsers to never downgrade with HSTS:
js1app.use((req, res, next) => { 2 res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') 3 next() 4})
Modern hosts and load balancers handle TLS termination for you. There's no excuse to run plain HTTP in production — none.
Every non-public endpoint requires authentication. Three mechanisms, for different callers:
Treat keys like passwords: hash them at rest, scope them to the minimum needed, and make them revocable.
js1app.use('/api', async (req, res, next) => { 2 const key = req.header('x-api-key') 3 const app = key && await lookupByKeyHash(hash(key)) 4 if (!app) return res.status(401).json({ error: 'Unauthorized' }) 5 req.app = app 6 next() 7})
Authentication is who you are; authorization is what you may touch. This is where APIs get breached most, so it earns its own layer.
RBAC gates actions by role. But the failure that dominates the OWASP list is Broken Object Level Authorization (BOLA) — also called IDOR. The user is authenticated, but the API returns someone else's object because it never checks ownership. Changing /orders/1001 to /orders/1002 should not reveal another customer's order.
js1app.get('/api/orders/:id', requireAuth, async (req, res) => { 2 const order = await getOrder(req.params.id) 3 // the check that stops BOLA/IDOR: 4 if (!order || order.ownerId !== req.user.id) { 5 return res.status(404).json({ error: 'Not found' }) 6 } 7 res.json(order) 8})
Every endpoint that takes an ID needs an ownership check. There is no shortcut, and no client-side substitute.
Without limits, one client can brute-force logins, scrape your whole dataset, or knock the service over. Cap requests per client and return 429 Too Many Requests when they exceed it:
js1import rateLimit from 'express-rate-limit' 2 3app.use('/api', rateLimit({ 4 windowMs: 15 * 60 * 1000, // 15 minutes 5 max: 100, // per IP per window 6 standardHeaders: true, 7 message: { error: 'Too many requests' }, 8})) 9 10// tighter limit on the endpoint attackers hammer 11app.use('/api/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }))
Login and password-reset routes deserve stricter limits than the rest of the API.
Never trust input — validate every field against a schema at the boundary, and reject anything that doesn't match with a 400. This closes off injection, type-confusion, and malformed-payload bugs. I use a schema validator like Zod:
js1import { z } from 'zod' 2 3const CreateTask = z.object({ 4 title: z.string().min(1).max(200), 5 dueAt: z.string().datetime().optional(), 6}) 7 8app.post('/api/tasks', requireAuth, (req, res) => { 9 const parsed = CreateTask.safeParse(req.body) 10 if (!parsed.success) return res.status(400).json({ error: parsed.error.issues }) 11 // parsed.data is now typed and safe 12})
And always use parameterized database queries — never string-concatenate input into SQL. Validation and parameterized queries together shut down injection.
Errors must not leak internals. A stack trace, SQL string, or file path in a response is a map of your system handed to an attacker. Return a generic message and a status code; log the detail server-side where only you can see it:
js1app.use((err, req, res, next) => { 2 logger.error({ err, path: req.path, userId: req.user?.id }) // full detail, server-side 3 res.status(err.status || 500).json({ error: 'Something went wrong' }) // generic, to client 4})
You can't respond to what you can't see. Log authentication events, authorization failures, and anomalies — then actually alert on them. A spike in 401s or 429s is often an attack in progress. Log the events, never the secrets: no passwords, tokens, or full card numbers in your logs.
For anything beyond a single service, put a gateway in front. It centralizes TLS termination, authentication, rate limiting, and logging in one place instead of scattering them across every service — one consistent enforcement point, and one place to update policy.
Secrets — database URLs, API keys, signing keys — never live in code or Git. A key committed to a repo is compromised the moment it's pushed, and rewriting history rarely fully removes it.
.env to .gitignore and commit a .env.example with blank values.bash1# .gitignore 2.env 3.env.local
If a secret ever lands in Git, rotate it — don't just delete the line and hope.
Security isn't a one-time pass. Run dependency scanning (npm audit, Dependabot) so a vulnerable package doesn't sneak in, add static analysis to CI, and test your own endpoints for BOLA by trying to access objects you don't own. Make it part of the pipeline, not a heroic quarterly effort.
These myths get people breached, so I say them plainly:
curl, a script, or a server making a direct request. It is not an access-control mechanism. (More in connect React to Node.)Security lives on the server. Always.
Run this before you ship, and again on every review:
| # | Control | Guards against | Status |
|---|---|---|---|
| 1 | HTTPS/TLS + HSTS | Eavesdropping, MITM | ☐ |
| 2 | Authentication on every private route | Anonymous access | ☐ |
| 3 | RBAC + object-level ownership checks | BOLA / IDOR | ☐ |
| 4 | Rate limiting → 429 | Brute force, scraping, DoS | ☐ |
| 5 | Schema validation + parameterized queries | Injection, bad input | ☐ |
| 6 | Generic error responses | Internal info leaks | ☐ |
| 7 | Logging + alerting (no secrets logged) | Blind spots | ☐ |
| 8 | API gateway | Inconsistent enforcement | ☐ |
| 9 | Secrets in a vault, rotated, never in Git | Credential leaks | ☐ |
| 10 | Dependency + endpoint security testing | Known CVEs, regressions | ☐ |
The authentication and authorization layers here go deeper in how to authenticate users in a web application — read it alongside this, since the two overlap. For where security fits in the full build, head back to the full-stack app guide.
The exact path I follow to ship a full-stack app end to end — data model, Node/Express API, database, React frontend, auth, security, and deploy — with links to the deep dives for each step.
Authentication has four parts: verifying identity, managing sessions, authorizing actions, and expiring access safely. I cover password hashing, sessions vs JWT, HttpOnly cookies, OAuth, passkeys, and the architecture I actually recommend.
A React app and a Node.js API are two separate programs talking over HTTP. I show the Express server, the React fetch, why CORS exists, how to set up a dev proxy, project structure, and how to run and deploy both.