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:
- Broken Object Level Authorization — accessing other users’ data by changing IDs
- Broken Authentication — weak or missing authentication
- Broken Object Property Level Authorization — mass assignment attacks
- Unrestricted Resource Consumption — no rate limiting, large payloads
- Broken Function Level Authorization — accessing admin endpoints
- Unrestricted Access to Sensitive Business Flows — automation abuse
- Server-Side Request Forgery (SSRF) — tricking server into making internal requests
- Security Misconfiguration — verbose errors, missing headers
- Improper Inventory Management — forgotten endpoints, old API versions
- 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-Afterheader — 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
- Validate type — is the email actually a string? Is the age actually a number?
- Validate length — reject inputs that are too long (prevents buffer issues and storage abuse)
- Validate format — does the email match the email pattern? Does the date make sense?
- Validate range — is the page number between 1 and 1000? Is the amount positive?
- 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
.jpgfile 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
- Never embed API keys in frontend code — they are visible to anyone
- Use environment variables — not hardcoded strings
- Rotate keys regularly — if a key is leaked, the window of exposure is limited
- Scope keys — give read-only keys to services that do not need write access
- 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
| Defense | Priority | Notes |
|---|---|---|
| Rate limiting on all endpoints | High | Stricter for auth endpoints |
| Input validation on the server | High | Never trust client-side validation alone |
| Request body size limits | High | Prevents memory exhaustion |
| Output filtering | High | Never expose internal fields |
| API key rotation | Medium | Rotate every 90 days minimum |
| Avoid logging sensitive data | High | Passwords, tokens, PII |
| Use HTTPS for all API calls | High | Prevents credential sniffing |
| Validate Content-Type header | Medium | Reject 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.