🔐 API Security Best Practices
An API is a public door into your data — and attackers knock on it constantly. This lesson walks through the layered defenses that turn that door into a well-guarded gate: proving who a caller is, deciding what they may do, encrypting the wire, distrusting every input, and watching everything that happens.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish authentication from authorization and implement both correctly in an Express API
- Validate and verify JWTs and store secrets and tokens safely
- Defend against the top injection and access-control flaws in the OWASP API Security Top 10
- Harden the transport and response layers with TLS, CORS, and security headers
- Add logging and monitoring so incidents are detected, not just suffered
Estimated Time: 45–60 minutes • Difficulty: Advanced
Hands-on: Harden a deliberately vulnerable Express endpoint, then verify each fix.
In This Lesson
Why API Security Is Different
A traditional web page ships HTML that a human reads. An API ships raw data that any program can call, at machine speed, forever. There is no friendly login form to hide behind and no browser to enforce good manners — just endpoints returning JSON. That openness is the whole point of an API, and also its greatest risk.
💡 A useful analogy: Good API security works like a bank, not a single lock. A bank has a guard at the door (authentication), a rule about which rooms you may enter (authorization), an armored transport for cash (TLS), a teller who checks every form (input validation), and cameras recording it all (logging). Remove any one layer and the others still hold. This is defense in depth.
Because APIs are called by scripts, three classes of problems dominate: broken access control (a caller reaches data that isn't theirs), injection (untrusted input becomes executable), and secrets mishandling (tokens or keys leak). Almost every real-world breach in this space traces back to one of those three. We'll spend the rest of the lesson closing each gap.
📖 Key Terms
Threat model: a deliberate list of who might attack you, what they want, and how — written down before you code.
Attack surface: every endpoint, parameter, and header an attacker can touch. Smaller is safer.
Defense in depth: layering independent controls so one failure isn't catastrophic.
The OWASP API Security Top 10
The OWASP API Security Top 10 (2023) is the industry's shared checklist of the most common, most damaging API weaknesses. You don't need to memorize the numbers, but you should recognize the shape of each risk.
| Risk | What goes wrong | Core fix |
|---|---|---|
| API1 — Broken Object Level Authorization (BOLA) | Caller changes an id in the URL and reads another user's record | Check ownership on every object access |
| API2 — Broken Authentication | Weak tokens, no expiry, guessable credentials | Standard protocols, short-lived tokens |
| API3 — Broken Object Property Level Authorization | Excessive data exposure or mass assignment | Explicit allow-lists for fields |
| API4 — Unrestricted Resource Consumption | No rate/size limits; costly queries | Rate limiting, pagination, quotas |
| API5 — Broken Function Level Authorization | A normal user calls an admin route | Role checks per route, deny by default |
| API8 — Security Misconfiguration | Debug endpoints, permissive CORS, missing headers | Hardened defaults, Helmet, least privilege |
Notice that five of the top ten are access-control problems. That's the single most important takeaway: authentication alone is never enough — you must also check authorization on every request and every object.
Authentication vs. Authorization
These two words are constantly confused, but they answer different questions:
- Authentication (AuthN) — Who are you? Verifying identity, usually with a token or credentials.
- Authorization (AuthZ) — What are you allowed to do? Deciding whether this identity may perform this action on this resource.
📖 Say it like this
Authentication is showing your passport at the airport. Authorization is whether your boarding pass lets you into the first-class lounge. Being who you say you are does not mean you may go everywhere.
Authenticate with a middleware
In Express, authentication is best expressed as middleware that runs before protected routes. Here we verify a bearer JWT and attach the decoded user to the request:
import jwt from 'jsonwebtoken';
// AuthN: prove the caller's identity from a signed token
function authenticate(req, res, next) {
const header = req.headers.authorization ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
// Throws if the signature is invalid or the token has expired
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
Authorize by role — and by ownership
Role checks (RBAC) handle function-level authorization. But the most common breach — BOLA — happens when you skip the object-level check. Do both:
// AuthZ (function level): does the role permit this route at all?
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Admin-only route: two independent layers stacked
app.get('/api/users', authenticate, requireRole('admin'), listUsers);
// AuthZ (object level): may THIS user touch THIS record?
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: 'Not found' });
// The critical BOLA guard — never trust the id alone
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
res.json(order);
});
⚠️ The number-one API bug
Returning an object just because the caller is logged in is Broken Object Level Authorization. Always verify that the authenticated user actually owns or is permitted the specific resource being requested. Never rely on the client to send only "their own" ids.
Store passwords with a slow hash
When you do handle credentials directly, never store them in plaintext. Use a purpose-built, deliberately slow hash such as bcrypt or argon2, which resists brute-forcing:
import bcrypt from 'bcrypt';
const COST = 12; // work factor — higher is slower and safer
async function register(username, password) {
const hash = await bcrypt.hash(password, COST);
await db.users.insert({ username, password: hash });
}
async function verify(username, password) {
const user = await db.users.findOne({ username });
// Constant-time comparison; returns false for unknown users too
return user ? bcrypt.compare(password, user.password) : false;
}
Working Safely with JWTs
JSON Web Tokens are the standard way to carry identity between an API and its clients. A JWT is three base64url parts — header, payload, signature — joined by dots. The signature is what makes it trustworthy: only a holder of the secret (or private key) can produce a valid one, so a tampered payload fails verification.
⚠️ A JWT is signed, not secret
Anyone can decode the payload — it is merely encoded, not encrypted. Never put passwords, card numbers, or anything sensitive inside a JWT. Put an opaque user id and a role, nothing more.
The lifetime of a token is your main safety dial. A stolen long-lived token is a disaster; a stolen 15-minute token is a nuisance. The standard pattern is a short-lived access token paired with a longer-lived refresh token that can be rotated and revoked:
import jwt from 'jsonwebtoken';
function issueTokens(user) {
const access = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // short — limits blast radius
);
const refresh = jwt.sign(
{ sub: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { access, refresh };
}
Where the token lives on the client matters. For browser apps, storing tokens in localStorage exposes them to any XSS payload. Prefer an HttpOnly, Secure, SameSite cookie, which JavaScript cannot read:
res.cookie('refresh', refresh, {
httpOnly: true, // unreachable from document.cookie / JS
secure: true, // only sent over HTTPS
sameSite: 'strict', // mitigates CSRF
maxAge: 7 * 24 * 60 * 60 * 1000
});
✅ Token hygiene checklist
- Always set an expiry (
expiresIn) — never mint an eternal token. - Pin the algorithm on verify (
{ algorithms: ['HS256'] }) to block thealg: noneforgery trick. - Keep a server-side revocation list (or short TTLs) so logout and compromise actually take effect.
- Rotate refresh tokens on each use; detect reuse of an old one as theft.
Input Validation & Injection
The golden rule of application security: never trust input. Every field, header, query string, and path parameter is attacker-controlled until you prove otherwise. Two habits handle the vast majority of injection risk — validate the shape of input, and parameterize the way it reaches other systems.
Validate at the boundary with a schema
Declare exactly what you accept, and reject everything else, before the data touches your logic. A schema library like Zod makes this concise and type-safe:
import { z } from 'zod';
const CreateUser = z.object({
username: z.string().min(3).max(30).regex(/^[a-z0-9_]+$/i),
email: z.string().email(),
age: z.number().int().min(18).max(120).optional(),
});
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
req.body = result.data; // only the known-good, coerced fields survive
next();
};
}
app.post('/api/users', validate(CreateUser), createUser);
💡 Validation also stops mass assignment
Because the schema returns only the fields it declares, a caller cannot sneak in { "role": "admin" } and have it silently bound to your model. Allow-listing fields is the fix for OWASP API3.
Parameterize — never concatenate
Injection happens when untrusted text is spliced into a command that another interpreter runs. The cure is always the same: send data and code separately.
// ❌ SQL injection: the username becomes part of the query
db.query(`SELECT * FROM users WHERE name = '${name}'`);
// ✅ Parameterized: the driver treats name strictly as a value
db.query('SELECT * FROM users WHERE name = $1', [name]);
// ❌ Command injection via a shell string
import { exec } from 'node:child_process';
exec(`convert ${file} out.png`);
// ✅ Pass args as an array — no shell parsing of user input
import { execFile } from 'node:child_process';
if (!/^[\w.\-]+$/.test(file)) throw new Error('bad filename');
execFile('convert', [file, 'out.png']);
The same principle covers NoSQL (validate that a value is a string, not a query object like { $gt: '' }) and output (encode data before it lands in HTML to prevent stored XSS).
Transport & Response Hardening
Even a perfectly-authorized request is exposed if it travels in the clear or if your responses leak clues. Three inexpensive controls close most of this gap.
1. HTTPS everywhere
Serve the API only over TLS 1.2+ and tell browsers to remember it with HSTS. In practice you terminate TLS at a proxy or platform, but the intent is the same: no plaintext, ever.
2. Security headers with Helmet
One line of middleware sets a battery of protective response headers (CSP, HSTS, X-Content-Type-Options, frame options, and more):
import helmet from 'helmet';
app.use(helmet()); // sensible secure defaults for all responses
app.use(helmet.hsts({ maxAge: 15552000, includeSubDomains: true, preload: true }));
3. A strict CORS policy
Cross-Origin Resource Sharing decides which web origins may call your API from a browser. The dangerous mistake is reflecting any origin while allowing credentials. Allow-list explicit origins instead:
import cors from 'cors';
const allowed = ['https://app.example.com', 'https://admin.example.com'];
app.use(cors({
origin: (origin, cb) =>
!origin || allowed.includes(origin)
? cb(null, true)
: cb(new Error('Origin not allowed')),
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // never combine credentials:true with origin:'*'
maxAge: 86400,
}));
⚠️ Fail closed, and stay quiet
When authorization fails, default to denying access. And keep error responses generic: a message like "user alice@example.com not found" tells an attacker which accounts exist. Return a neutral 401/403 and log the detail server-side.
Logging & Monitoring
You cannot respond to an attack you cannot see. Structured, searchable logs turn a silent breach into an alert. Record the security-relevant events — successful and failed authentications, authorization denials, validation failures, and rate-limit hits — with enough context to investigate.
import winston from 'winston';
const security = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json() // machine-parseable for your SIEM
),
transports: [new winston.transports.File({ filename: 'security.log' })],
});
function onAuthFailure(req) {
security.warn({
event: 'auth_failure',
path: req.originalUrl,
ip: req.ip,
userAgent: req.get('user-agent'),
// Never log tokens, passwords, or full request bodies
});
}
⚠️ Logs are a leak vector too
The 2020s are littered with breaches caused by tokens, API keys, and passwords written into log files. Redact secrets before logging, and restrict who can read the logs. Log the fact of an event, not the credentials involved.
Pair logging with alerting (a spike in 401s, a burst of 403s from one IP) and a written incident-response plan so that when — not if — something trips the alarm, the team knows the steps to contain, eradicate, and recover.
Hands-on: Harden an Endpoint
🏋️ Fix the vulnerable order lookup
Objective: Take a naive endpoint and apply the layers from this lesson until it is safe.
Here is the starting code. It has at least three distinct security flaws:
app.get('/api/orders/:id', (req, res) => {
const order = db.query(
`SELECT * FROM orders WHERE id = '${req.params.id}'`
);
res.json(order);
});
Instructions:
- Name each flaw you can find (hint: think authentication, injection, and access control).
- Rewrite the endpoint so it authenticates the caller, parameterizes the query, and enforces object-level ownership.
- Make the "not found" and "not yours" cases both return responses that don't leak whether the id exists.
💡 Hint
The three flaws are: (1) no authentication — anyone can call it; (2) SQL injection — req.params.id is concatenated into the query; (3) BOLA — no check that the order belongs to the caller. Reuse the authenticate middleware from Section 3 and a parameterized query from Section 5.
✅ Sample solution
app.get('/api/orders/:id', authenticate, async (req, res) => {
// Parameterized query — no injection
const order = await db.query(
'SELECT * FROM orders WHERE id = $1',
[req.params.id]
);
// Same generic response whether missing or forbidden
if (!order || (order.userId !== req.user.id && req.user.role !== 'admin')) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
Returning 404 for both the missing and the forbidden case is a deliberate choice: it prevents an attacker from mapping which order ids exist by watching for 403 vs 404.
🎯 Quick Quiz
Question 1: A logged-in user changes /api/orders/42 to /api/orders/43 and sees someone else's order. Which OWASP risk is this?
Question 2: Why should sensitive data never be placed inside a JWT payload?
Question 3: What is the reliable defense against SQL injection?
Best Practices
✅ Do
- Check authorization on every request and every object — not just at login.
- Validate input against an explicit schema and allow-list fields.
- Use parameterized queries and array-form command execution everywhere.
- Keep access tokens short-lived; store them in
HttpOnlycookies for browsers. - Apply Helmet, strict CORS, and TLS as baseline defaults.
- Log security events and alert on anomalies.
⚠️ Don't
- Don't trust a client-supplied id, role, or "isAdmin" flag.
- Don't roll your own crypto or authentication protocol.
- Don't put secrets in JWTs, URLs, or logs.
- Don't reflect arbitrary origins with
credentials: truein CORS. - Don't reveal in error messages whether an account or record exists.
Summary & Quiz
🎉 Key Takeaways
- API security is defense in depth: TLS, authentication, rate limiting, validation, and authorization, each independent.
- Authentication answers "who," authorization answers "what" — and access-control flaws dominate the OWASP API Top 10.
- Always enforce object-level ownership to prevent BOLA, the most common API breach.
- Treat JWTs as signed-but-readable; keep them short-lived and out of
localStorage. - Never trust input: validate with schemas and parameterize every query and command.
- Harden responses with Helmet, strict CORS, and log security events for detection.
📚 Further Reading
🚀 What's Next?
One of the OWASP risks we touched — unrestricted resource consumption — deserves a lesson of its own. Next we dig into rate limiting and throttling: the algorithms and patterns that keep a single caller from overwhelming your API.
🎉 Well defended!
You can now reason about an API's attack surface and layer real controls against it.