In the previous tutorial, you learned how CORS controls cross-origin access to your API. But CORS is just one layer. APIs are the most attacked surface in modern applications, and they need multiple defenses. In this article, you will learn rate limiting, input validation, and API authentication best practices.

Why API Security Matters

Every mobile app, SPA, and microservice communicates through APIs. If your API is insecure, everything built on top of it is insecure.

The OWASP API Security Top 10 (2023) lists the most common API vulnerabilities:

  1. Broken Object Level Authorization — accessing other users’ data by changing IDs
  2. Broken Authentication — weak or missing authentication
  3. Broken Object Property Level Authorization — mass assignment attacks
  4. Unrestricted Resource Consumption — no rate limiting, large payloads
  5. Broken Function Level Authorization — accessing admin endpoints
  6. Unrestricted Access to Sensitive Business Flows — automation abuse
  7. Server-Side Request Forgery (SSRF) — tricking server into making internal requests
  8. Security Misconfiguration — verbose errors, missing headers
  9. Improper Inventory Management — forgotten endpoints, old API versions
  10. Unsafe Consumption of APIs — trusting third-party APIs blindly

In this tutorial, we focus on the most practical defenses: rate limiting, input validation, and authentication.

Rate Limiting: Stop Brute Force and Abuse

Without rate limiting, an attacker can:

  • Brute-force passwords (millions of login attempts)
  • Scrape your entire database through list endpoints
  • Cause a denial of service by flooding your server
  • Abuse expensive operations (email sending, SMS, AI inference)

Rate Limiting Algorithms

Token Bucket — the most common algorithm. Each user gets a bucket of tokens. Every request consumes a token. Tokens refill at a fixed rate. When the bucket is empty, requests are rejected.

Sliding Window — tracks requests in a time window (e.g., 100 requests per minute). More precise than fixed windows, which can allow bursts at window boundaries.

Fixed Window — simple but allows edge bursts. A user could make 100 requests at 11:59 and 100 more at 12:00 — 200 in 2 seconds.

Rate Limiting in Go

package main

import (
    "net"
    "net/http"
    "sync"

    "golang.org/x/time/rate"
)

// Per-IP rate limiter
type IPRateLimiter struct {
    mu       sync.RWMutex
    limiters map[string]*rate.Limiter
    rate     rate.Limit
    burst    int
}

func NewIPRateLimiter(r rate.Limit, burst int) *IPRateLimiter {
    return &IPRateLimiter{
        limiters: make(map[string]*rate.Limiter),
        rate:     r,
        burst:    burst,
    }
}

func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
    i.mu.Lock()
    defer i.mu.Unlock()

    limiter, exists := i.limiters[ip]
    if !exists {
        limiter = rate.NewLimiter(i.rate, i.burst)
        i.limiters[ip] = limiter
    }
    return limiter
}

// 10 requests per second, burst of 20
var limiter = NewIPRateLimiter(10, 20)

func rateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip, _, err := net.SplitHostPort(r.RemoteAddr)
        if err != nil {
            ip = r.RemoteAddr // Fallback if no port
        }

        if !limiter.GetLimiter(ip).Allow() {
            w.Header().Set("Retry-After", "1")
            http.Error(w, "Too many requests", http.StatusTooManyRequests)
            return
        }

        next.ServeHTTP(w, r)
    })
}

Rate Limiting in Python (Flask)

# Install: pip install flask-limiter
from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

app = Flask(__name__)

limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["100 per minute"],  # Global default
    storage_uri="redis://localhost:6379",  # Use Redis for distributed apps
)

@app.route("/api/login", methods=["POST"])
@limiter.limit("5 per minute")  # Strict limit for login
def login():
    return {"message": "Login endpoint"}

@app.route("/api/users")
@limiter.limit("30 per minute")  # Moderate limit for data
def get_users():
    return {"users": []}

@app.route("/api/public/health")
@limiter.exempt  # No rate limit for health checks
def health():
    return {"status": "ok"}

Rate Limiting in JavaScript (Express)

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

const app = express();

// Global rate limit: 100 requests per 15 minutes
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true, // Return rate limit info in headers
  legacyHeaders: false,
  message: { error: "Too many requests, please try again later" },
});

// Strict limit for auth endpoints: 5 per minute
const authLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 5,
  message: { error: "Too many login attempts" },
});

app.use(globalLimiter);
app.use("/api/login", authLimiter);
app.use("/api/register", authLimiter);

app.post("/api/login", (req, res) => {
  res.json({ message: "Login endpoint" });
});

Rate Limiting Best Practices

  • Different limits for different endpoints — login should be stricter than reading data
  • Rate limit by user ID, not just IP — shared IPs (offices, VPNs) affect multiple users
  • Return Retry-After header — tell clients when they can try again
  • Use Redis or Memcached for distributed rate limiting across multiple servers
  • Log rate-limited requests — they may indicate an attack

Input Validation: Never Trust User Input

Every piece of data from the client is potentially malicious. URLs, headers, query parameters, JSON bodies, file uploads — all of them.

Validation Rules

  1. Validate type — is the email actually a string? Is the age actually a number?
  2. Validate length — reject inputs that are too long (prevents buffer issues and storage abuse)
  3. Validate format — does the email match the email pattern? Does the date make sense?
  4. Validate range — is the page number between 1 and 1000? Is the amount positive?
  5. Whitelist, do not blacklist — define what IS allowed, not what is NOT allowed

Input Validation in Go

package main

import (
    "encoding/json"
    "net/http"
    "regexp"
    "strings"
)

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)

func validateCreateUser(req CreateUserRequest) []string {
    var errors []string

    // Validate name
    name := strings.TrimSpace(req.Name)
    if len(name) < 1 || len(name) > 100 {
        errors = append(errors, "Name must be between 1 and 100 characters")
    }

    // Validate email format
    if !emailRegex.MatchString(req.Email) {
        errors = append(errors, "Invalid email format")
    }
    if len(req.Email) > 254 {
        errors = append(errors, "Email too long")
    }

    // Validate age range
    if req.Age < 13 || req.Age > 150 {
        errors = append(errors, "Age must be between 13 and 150")
    }

    return errors
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    // Limit request body size (1MB max)
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    if errors := validateCreateUser(req); len(errors) > 0 {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]interface{}{
            "errors": errors,
        })
        return
    }

    // Process valid request...
    json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}

Input Validation in Python (Pydantic)

from pydantic import BaseModel, EmailStr, Field, field_validator
from fastapi import FastAPI, HTTPException

app = FastAPI()

class CreateUserRequest(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: EmailStr  # Validates email format automatically
    age: int = Field(ge=13, le=150)

    @field_validator("name")
    @classmethod
    def name_must_not_be_empty(cls, v):
        if not v.strip():
            raise ValueError("Name cannot be blank")
        return v.strip()

@app.post("/api/users")
async def create_user(user: CreateUserRequest):
    # Pydantic validates automatically — invalid data raises 422
    return {"status": "created", "name": user.name}

Input Validation in JavaScript (Zod)

import express from "express";
import { z } from "zod";

const app = express();
app.use(express.json({ limit: "1mb" })); // Limit body size

// Define validation schema
const createUserSchema = z.object({
  name: z.string().min(1).max(100).trim(),
  email: z.string().email().max(254),
  age: z.number().int().min(13).max(150),
});

app.post("/api/users", (req, res) => {
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      errors: result.error.issues.map((i) => i.message),
    });
  }

  // result.data is validated and typed
  const user = result.data;
  res.json({ status: "created", name: user.name });
});

Common Validation Mistakes

  • Validating only on the client — anyone can bypass frontend validation with curl or Postman
  • Missing length limits — a 10GB JSON body can crash your server
  • Trusting file extensions — a .jpg file can contain PHP code
  • Not sanitizing for output — validation prevents bad data in; output encoding prevents XSS going out

API Authentication: Keys vs Tokens vs OAuth

Different APIs need different authentication methods.

API Keys

An API key is a long random string that identifies the caller. It is simple but limited.

GET /api/weather?city=Berlin
Authorization: Bearer sk_live_abc123def456

When to use: Server-to-server calls, public APIs with usage tracking, third-party integrations.

When NOT to use: User-facing authentication (API keys do not represent a user).

// Go: API key middleware
import (
    "net/http"
    "strings"
)

func apiKeyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        key := r.Header.Get("Authorization")
        if key == "" {
            key = r.URL.Query().Get("api_key") // Fallback to query param
        }

        key = strings.TrimPrefix(key, "Bearer ")

        if !isValidAPIKey(key) {
            http.Error(w, "Invalid API key", http.StatusUnauthorized)
            return
        }

        next.ServeHTTP(w, r)
    })
}

JWT Tokens

JWTs contain user identity and claims, signed by the server. They are stateless — the server does not need to look up a session.

When to use: User authentication in SPAs, mobile apps, microservices.

See: Security for Developers #2: Authentication for JWT implementation details.

OAuth 2.0 Tokens

OAuth is used when a third party needs access to user resources. For example, “Login with Google” or a GitHub integration that reads your repos.

When to use: Third-party integrations, social login, delegated authorization.

See: Security for Developers #3: Authorization for OAuth implementation.

API Key Security Rules

  1. Never embed API keys in frontend code — they are visible to anyone
  2. Use environment variables — not hardcoded strings
  3. Rotate keys regularly — if a key is leaked, the window of exposure is limited
  4. Scope keys — give read-only keys to services that do not need write access
  5. Log key usage — detect unusual patterns

Request Size Limits

Without size limits, an attacker can send enormous payloads to exhaust your server’s memory.

// Go: limit request body to 1MB
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
# Django settings.py
DATA_UPLOAD_MAX_MEMORY_SIZE = 1048576  # 1MB

# FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse

@app.middleware("http")
async def limit_body_size(request: Request, call_next):
    if request.headers.get("content-length"):
        if int(request.headers["content-length"]) > 1_048_576:
            return JSONResponse(status_code=413, content={"error": "Payload too large"})
    return await call_next(request)
// Express: limit JSON body size
app.use(express.json({ limit: "1mb" }));

Output Filtering: Return Only What Is Needed

Never return the entire database object. Expose only the fields the client needs.

// BAD — returns everything including password hash
func getUser(w http.ResponseWriter, r *http.Request) {
    user := db.FindUser(id) // { id, name, email, password_hash, is_admin }
    json.NewEncoder(w).Encode(user) // Exposes password_hash and is_admin
}

// GOOD — return only what the client needs
type UserResponse struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func getUser(w http.ResponseWriter, r *http.Request) {
    user := db.FindUser(id)
    response := UserResponse{ID: user.ID, Name: user.Name, Email: user.Email}
    json.NewEncoder(w).Encode(response)
}

What NOT to Log

Your API logs are useful for debugging and security monitoring. But logging the wrong data creates a new vulnerability.

Never log:

  • Passwords or password hashes
  • API keys or tokens
  • Credit card numbers
  • Personal data (addresses, phone numbers, national IDs)
// BAD
log.Printf("Login attempt: user=%s password=%s", username, password)

// GOOD
log.Printf("Login attempt: user=%s success=%v ip=%s", username, success, ip)

Prevention Checklist

DefensePriorityNotes
Rate limiting on all endpointsHighStricter for auth endpoints
Input validation on the serverHighNever trust client-side validation alone
Request body size limitsHighPrevents memory exhaustion
Output filteringHighNever expose internal fields
API key rotationMediumRotate every 90 days minimum
Avoid logging sensitive dataHighPasswords, tokens, PII
Use HTTPS for all API callsHighPrevents credential sniffing
Validate Content-Type headerMediumReject unexpected content types

What is Next?

In the next tutorial, you will learn about managing secrets — environment variables, secret vaults, and key rotation. Hardcoded secrets in source code are one of the most common security mistakes, and this article will show you how to avoid them.