Skip to main content
Security7 min readMarch 3, 2026

API Security Best Practices: Protecting Your Endpoints in Production

Practical API security best practices — authentication schemes, rate limiting, input validation, output filtering, and the production security controls every API needs.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

API Security Best Practices: Protecting Your Endpoints in Production

APIs are the attack surface of modern applications. Your frontend is largely irrelevant from a security perspective — a determined attacker ignores your UI and talks directly to your API endpoints. Every input validation you do only in JavaScript is bypassed. Every "hidden" endpoint that your UI does not display is still accessible. Every endpoint that does not check authorization is exploitable.

Building secure APIs requires thinking like someone who will never see your frontend. Here is what that looks like in practice.

Authentication: Knowing Who Is Calling

Every non-public API endpoint must verify the caller's identity before processing the request. The two dominant patterns are JWT (JSON Web Tokens) and session-based authentication. They have different tradeoffs.

JWT — the caller presents a signed token with claims embedded. The server validates the signature and trusts the claims without a database lookup. This is stateless and scales well. The downside: JWTs cannot be invalidated before expiry without a blacklist (which adds the database lookup you were trying to avoid). Use short expiry times (15 minutes) with refresh tokens.

Session-based — the server issues a session ID stored in a cookie. Every request looks up the session in a database or cache. This allows instant session invalidation (logout destroys the session). The downside: every request requires a database/cache lookup.

For most applications, session-based authentication is simpler and more secure (because logout actually works). JWTs are appropriate when you need stateless horizontal scaling or when you are issuing tokens for third-party API access.

Regardless of which you use:

// Middleware that authenticates every protected route
export async function authenticate(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const token = req.headers.authorization?.replace("Bearer ", "");

  if (!token) {
    res.status(401).json({ error: "Authentication required" });
    return;
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    req.user = { id: payload.sub!, email: payload.email };
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

Never put authentication in the frontend route matching logic — put it in middleware that runs on every protected endpoint, before any request processing.

Authorization: What Callers Can Do

Authentication establishes identity. Authorization determines what that identity can access. These are separate concerns. A common mistake is to conflate them: "the user is authenticated, so they can access anything."

Authorization must be enforced at the data layer, not just the route level. Every database query for user-specific data must filter by the authenticated user's context:

// Broken access control — returns any resource by ID
async function getDocument(id: string) {
  return db.document.findById(id);
}

// Correct — enforces ownership
async function getDocument(id: string, userId: string) {
  const doc = await db.document.findFirst({
    where: { id, userId },
  });
  if (!doc) throw new NotFoundError();
  return doc;
}

For role-based access control (RBAC), check permissions for specific operations, not just user roles:

function requirePermission(permission: Permission) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user.permissions.includes(permission)) {
      res.status(403).json({ error: "Insufficient permissions" });
      return;
    }
    next();
  };
}

app.delete(
  "/api/documents/:id",
  authenticate,
  requirePermission("documents:delete"),
  deleteDocument
);

Rate Limiting

Every public API endpoint needs rate limiting. Without it, your API is vulnerable to:

  • Brute-force attacks on authentication endpoints
  • Credential stuffing (trying leaked username/password combinations)
  • Scraping your data at machine speed
  • Denial of service through request volume

Use a sliding window rate limiter per IP (or per user for authenticated endpoints):

import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";

// Strict rate limit for authentication endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 attempts per window
  store: new RedisStore({ client: redisClient }),
  message: { error: "Too many login attempts, please try again later" },
  standardHeaders: true,
});

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  store: new RedisStore({ client: redisClient }),
});

app.use("/api/auth", authLimiter);
app.use("/api", apiLimiter);

Using Redis as the store is important — in-memory storage does not work across multiple instances or restart boundaries. Persist rate limit state in Redis so it survives instance restarts and works correctly in horizontally scaled deployments.

Input Validation

Validate every input, every time. Not just presence — validate type, length, format, and range:

import { z } from "zod";

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(50000),
  tags: z.array(z.string().max(50)).max(10).optional(),
  publishedAt: z.string().datetime().optional(),
});

app.post("/api/posts", authenticate, async (req, res) => {
  const result = createPostSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.flatten(),
    });
  }

  const post = await createPost(result.data, req.user.id);
  res.status(201).json(post);
});

Validate on the server regardless of any client-side validation. Client-side validation is UX, not security. Assume every request to your API bypasses your frontend completely.

Output Filtering: Return Only What Is Needed

Never return more data than the caller needs. Your user profile endpoint should not return the password hash, the MFA secret, internal user flags, or other internal fields just because they are on the user object.

Define explicit response schemas and transform your data to match them:

function sanitizeUser(user: User): PublicUser {
  return {
    id: user.id,
    username: user.username,
    displayName: user.displayName,
    avatarUrl: user.avatarUrl,
    createdAt: user.createdAt,
    // Explicitly exclude: passwordHash, mfaSecret, internalFlags, adminNotes
  };
}

app.get("/api/users/:id", async (req, res) => {
  const user = await db.user.findById(req.params.id);
  if (!user) return res.status(404).json({ error: "Not found" });
  res.json(sanitizeUser(user));
});

This pattern also protects against accidentally adding a new field to your database model and automatically including it in API responses before you intended to.

CORS Configuration

CORS (Cross-Origin Resource Sharing) controls which origins can make requests to your API from a browser. The default browser policy blocks cross-origin requests. CORS headers relax this policy.

Configure CORS explicitly and restrictively:

import cors from "cors";

app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(",") ?? ["https://yourapp.com"],
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  maxAge: 86400, // Cache preflight for 24 hours
}));

Never use origin: "*" for APIs that use cookie-based authentication or serve sensitive data. * prevents cookies from being sent with cross-origin requests (the browser blocks it), so it does not work with credentials anyway — but the intent of a wildcard CORS policy is a signal that authorization enforcement may be lax.

API Keys for Third-Party Access

If you issue API keys for third-party access, treat them as credentials:

  • Generate keys with at least 128 bits of entropy (random, not guessable)
  • Hash keys before storing (store the hash, return the plain key once at creation)
  • Associate keys with specific scopes and permissions
  • Log all API key usage
  • Allow key rotation and revocation
  • Set key expiry by default
import { randomBytes, createHash } from "crypto";

function generateApiKey(): { key: string; hash: string } {
  const key = `ak_${randomBytes(32).toString("hex")}`;
  const hash = createHash("sha256").update(key).digest("hex");
  return { key, hash };
}

Display the key to the user once at creation. Store only the hash. If the user loses the key, they generate a new one — you cannot retrieve it.

Request Logging for Security Audit

Log enough information to reconstruct what happened when you investigate a security incident:

app.use((req, res, next) => {
  const start = Date.now();
  res.on("finish", () => {
    logger.info({
      method: req.method,
      path: req.path,
      statusCode: res.statusCode,
      userId: req.user?.id,
      ip: req.ip,
      durationMs: Date.now() - start,
      userAgent: req.headers["user-agent"],
    }, "API request");
  });
  next();
});

This log record gives you: who made the request (user ID), from where (IP), what they requested (method + path), whether it succeeded (status code), and how long it took. This is the minimum needed for security audit and incident investigation.


If you want a security review of your API or help implementing the controls described here, book a session at https://calendly.com/jamesrossjr.


Keep Reading