Skip to main content

🔐 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.

RiskWhat goes wrongCore fix
API1 — Broken Object Level Authorization (BOLA)Caller changes an id in the URL and reads another user's recordCheck ownership on every object access
API2 — Broken AuthenticationWeak tokens, no expiry, guessable credentialsStandard protocols, short-lived tokens
API3 — Broken Object Property Level AuthorizationExcessive data exposure or mass assignmentExplicit allow-lists for fields
API4 — Unrestricted Resource ConsumptionNo rate/size limits; costly queriesRate limiting, pagination, quotas
API5 — Broken Function Level AuthorizationA normal user calls an admin routeRole checks per route, deny by default
API8 — Security MisconfigurationDebug endpoints, permissive CORS, missing headersHardened 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.

Layered defenses around an API request A request passes through concentric layers: TLS, authentication, rate limiting, input validation, and authorization before reaching business logic. Incoming request travels inward through each control TLS — encrypt the wire AuthN + Rate limit — who are you, how often Validate input — distrust every field AuthZ — may you touch THIS object? → business logic
Figure 1 — Each layer is independent. A request only reaches your business logic after surviving all of them.

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 the alg: none forgery 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:

  1. Name each flaw you can find (hint: think authentication, injection, and access control).
  2. Rewrite the endpoint so it authenticates the caller, parameterizes the query, and enforces object-level ownership.
  3. 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 injectionreq.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 HttpOnly cookies 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: true in 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.