How to Secure an API: A Practical Checklist
A layered, defense-in-depth checklist for securing an API — HTTPS, auth, RBAC and object-level checks against BOLA/IDOR, rate limiting, input validation, safe errors, logging, secret management, and the OWASP API Security Top 10. Plus the myths that get people breached.
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.
1. HTTPS/TLS everywhere
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:
1app.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.
2. Authentication: prove who's calling
Every non-public endpoint requires authentication. Three mechanisms, for different callers:
- OAuth 2.0 / JWT — for users and delegated access (covered in the auth guide).
- API keys — for server-to-server. Critical distinction: API keys identify an application, not a user. They say "this app is calling," not "this person is authorized." Don't use a key as proof of user identity.
Treat keys like passwords: hash them at rest, scope them to the minimum needed, and make them revocable.
1app.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})3. Authorization: enforce what they can do
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.
1app.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.
4. Rate limiting
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:
1import 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.
5. Input validation and schema
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:
1import { 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.
6. Safe error handling
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:
1app.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})7. Logging and monitoring
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.
8. API gateway
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.
9. Secret management
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.
- Load secrets from environment variables or a secrets manager (Vault, AWS Secrets Manager, Doppler).
- Add
.envto.gitignoreand commit a.env.examplewith blank values. - Rotate secrets regularly and immediately after any suspected exposure.
1# .gitignore
2.env
3.env.localIf a secret ever lands in Git, rotate it — don't just delete the line and hope.
10. Continuous security testing
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.
Reality check: what does NOT secure your API
These myths get people breached, so I say them plainly:
- CORS is not security. CORS controls which browser origins can read responses. It does nothing against
curl, a script, or a server making a direct request. It is not an access-control mechanism. (More in connect React to Node.) - You cannot hide a public API. If a browser or mobile app can call it, the endpoint, its shape, and any embedded keys are discoverable. Obscurity is not protection.
- Client-side checks are not enforcement. Disabling a button or validating in React is UX. Every rule must be re-checked on the server, because the client can be bypassed entirely.
Security lives on the server. Always.
The checklist
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 | ☐ |
Where to go next
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.