Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Security Best Practices

API Security Best Practices (2026): Complete Developer Guide

The complete API security best practices guide for 2026: authentication, authorization, rate limiting, input validation, CORS, and OWASP API Top 10 — with working code examples.

Offensive360 Security Research Team — min read
api security best practices API security REST API security OWASP API Security API authentication rate limiting API authorization CORS JWT security API security 2026

API security failures now account for the majority of data breaches. The 2023 OWASP API Security Top 10 documents the most critical risks, and real-world incidents at Twitter, T-Mobile, Optus, and countless others trace back to a handful of preventable mistakes in API design and implementation.

This guide covers the essential API security best practices every development team should implement in 2026 — with concrete code examples, common pitfalls, and how to test your APIs for each weakness.


Why API Security Is Different from Web App Security

APIs introduce a distinct set of security challenges that traditional web application security guidance doesn’t fully address:

  • No browser-enforced protections — APIs are consumed by mobile apps, scripts, and third-party integrations that don’t have browser security policies
  • Richer data access — APIs typically expose direct access to data models, making over-exposure of sensitive fields easy to overlook
  • Multiple consumers — a single API serves web apps, mobile apps, partner integrations, and internal microservices — each with different trust levels
  • Documentation exposes the attack surface — Swagger/OpenAPI specs and GraphQL introspection give attackers a complete map of your endpoints

The OWASP API Security Top 10 was created precisely because standard web application guidance is insufficient for modern APIs.


1. Enforce Authentication on Every Endpoint

Every API endpoint that accesses user data or performs any action must require authentication. Unauthenticated endpoints should be an explicit, reviewed decision — not an oversight.

JWT Authentication

JSON Web Tokens are the most common API authentication mechanism. The critical security requirements:

// SECURE JWT validation — Node.js/Express
const verifyToken = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  const token = authHeader.substring(7);

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],  // CRITICAL: always specify algorithms explicitly
      issuer: 'api.yourapp.com',
      audience: 'yourapp-clients',
    });
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
};

JWT security pitfalls to avoid:

  • algorithms: ['none'] — allows attackers to submit unsigned tokens
  • Not validating exp (expiry), iss (issuer), or aud (audience) claims
  • Storing JWT secrets in source code instead of environment variables
  • Using the same secret for signing and verification across multiple services

API Key Authentication

For machine-to-machine APIs and partner integrations:

import hmac
import os

def require_api_key(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        api_key = request.headers.get('X-API-Key')
        if not api_key:
            return jsonify({'error': 'API key required'}), 401

        # Use constant-time comparison to prevent timing attacks
        valid_key = os.environ['API_SECRET_KEY']
        if not hmac.compare_digest(api_key.encode(), valid_key.encode()):
            return jsonify({'error': 'Invalid API key'}), 401

        return f(*args, **kwargs)
    return decorated

2. Implement Object-Level Authorization (Fix BOLA)

Broken Object Level Authorization (BOLA) — also called IDOR (Insecure Direct Object Reference) — is the #1 risk in the OWASP API Security Top 10. It occurs when an API endpoint accepts an object identifier in the request but fails to verify that the requesting user has permission to access that specific object.

# VULNERABLE — authenticates the user but doesn't check ownership
@app.route('/api/v1/invoices/<int:invoice_id>')
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.get_or_404(invoice_id)
    return jsonify(invoice.to_dict())
    # Any authenticated user can access ANY invoice by changing the ID

# SECURE — always filter by the authenticated user's identity
@app.route('/api/v1/invoices/<int:invoice_id>')
@require_auth
def get_invoice(invoice_id):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        organization_id=current_user.organization_id  # Ownership check
    ).first_or_404()
    return jsonify(invoice.to_dict())

Testing for BOLA:

  1. Authenticate as User A and retrieve a resource ID (e.g., /api/orders/12345)
  2. Authenticate as User B and attempt to access the same ID
  3. If User B gets a 200 response, the endpoint is vulnerable

3. Rate Limit All Endpoints — Especially Authentication

Without rate limiting, APIs are vulnerable to brute force attacks, credential stuffing, enumeration, and denial-of-service.

// Express rate limiting — different limits per endpoint sensitivity
const rateLimit = require('express-rate-limit');

// Very strict for authentication endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 10,                    // 10 attempts per IP
  skipSuccessfulRequests: false,
  message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
  standardHeaders: true,
  legacyHeaders: false,
});

// Moderate for general API
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,   // 1 minute
  max: 120,              // 120 requests per minute per IP
  standardHeaders: true,
});

// Strict for password reset / account creation (anti-enumeration)
const sensitiveActionLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,  // 1 hour
  max: 5,
});

app.post('/api/auth/login', authLimiter, loginController);
app.post('/api/auth/forgot-password', sensitiveActionLimiter, forgotPasswordController);
app.post('/api/auth/register', sensitiveActionLimiter, registerController);
app.use('/api/', apiLimiter);

For distributed systems, use Redis-backed rate limiting (e.g., rate-limit-redis) to enforce limits across multiple server instances.


4. Validate and Sanitize All Input

Never trust API input regardless of where it originates — this applies to mobile app requests, partner integrations, and internal service calls.

// TypeScript + Zod — schema validation at the API boundary
import { z } from 'zod';

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive().max(100),  // Reasonable upper bound
  shippingAddress: z.object({
    street: z.string().min(1).max(200),
    city: z.string().min(1).max(100),
    countryCode: z.string().length(2).toUpperCase(),
    postalCode: z.string().regex(/^[A-Z0-9\s-]{3,10}$/i),
  }),
  couponCode: z.string().max(50).optional(),
});

app.post('/api/orders', requireAuth, async (req, res) => {
  const result = CreateOrderSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.issues,
    });
  }

  // result.data is now fully validated and type-safe
  const order = await createOrder(result.data, req.user);
  return res.status(201).json(order);
});

What to validate:

  • Type (string, integer, boolean)
  • Format (email, UUID, ISO date, country code)
  • Length/range bounds
  • Allowed values (enums, allowlists)
  • Character restrictions (especially for fields used in further processing)

5. Prevent Excessive Data Exposure

APIs frequently return entire data objects when only a subset is needed. This exposes sensitive fields to clients — and to attackers who intercept responses.

# VULNERABLE — returns raw model including sensitive internal fields
class UserResource(Resource):
    def get(self, user_id):
        user = User.query.get_or_404(user_id)
        return user.__dict__
        # Returns: id, email, password_hash, is_admin, internal_score,
        #          created_at, last_login_ip, mfa_secret, ...

# SECURE — explicit response schema
from marshmallow import Schema, fields

class PublicUserSchema(Schema):
    id = fields.Int()
    display_name = fields.Str()
    avatar_url = fields.Str()
    joined_date = fields.Date()
    # password_hash, is_admin, mfa_secret — intentionally excluded

public_user_schema = PublicUserSchema()

class UserResource(Resource):
    def get(self, user_id):
        user = User.query.get_or_404(user_id)
        return public_user_schema.dump(user)

Also applies to error responses. Never expose stack traces, database schema names, internal file paths, or server version information in API error messages.


6. Fix CORS Configuration

Permissive CORS configurations allow any website to make cross-origin requests to your API using a visitor’s authenticated session.

// DANGEROUS — allows any origin with credentials
app.use(cors({
  origin: '*',
}));

// ALSO DANGEROUS — reflects the request origin without validation
app.use(cors({
  origin: (origin, callback) => callback(null, true),  // Reflects everything
  credentials: true,
}));

// SECURE — explicit allowlist
const ALLOWED_ORIGINS = new Set([
  'https://app.yourcompany.com',
  'https://yourcompany.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean));

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (server-to-server, mobile apps)
    if (!origin || ALLOWED_ORIGINS.has(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`CORS: origin ${origin} not permitted`));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
}));

7. Protect Against Mass Assignment

Mass assignment occurs when an API automatically binds all request properties to a data model, including fields that should never be user-controlled — such as is_admin, role, account_balance, or email_verified.

# VULNERABLE — user can set is_admin=true or role=admin in the request
@app.route('/api/users/me', methods=['PATCH'])
@require_auth
def update_profile():
    current_user.update(**request.json)  # Accepts ALL fields
    db.session.commit()
    return jsonify(current_user.to_dict())

# SECURE — allowlist the only fields users should be able to change
MUTABLE_PROFILE_FIELDS = frozenset({
    'display_name',
    'bio',
    'avatar_url',
    'notification_preferences',
    'timezone',
})

@app.route('/api/users/me', methods=['PATCH'])
@require_auth
def update_profile():
    # Only apply fields from the allowlist
    updates = {k: v for k, v in request.json.items()
               if k in MUTABLE_PROFILE_FIELDS}
    if not updates:
        return jsonify({'error': 'No valid fields to update'}), 400

    current_user.update(**updates)
    db.session.commit()
    return jsonify(current_user.to_dict())

8. Secure GraphQL APIs

GraphQL requires security controls beyond standard REST API hardening because its flexible query language enables new attack vectors.

// Apollo Server — production security configuration
const server = new ApolloServer({
  typeDefs,
  resolvers,

  // Disable introspection in production — prevents schema enumeration
  introspection: process.env.NODE_ENV !== 'production',

  plugins: [
    // Depth limiting — prevents deeply nested queries that cause DoS
    ApolloServerPluginDepthLimit(5),
  ],
});

// Query complexity analysis — block expensive queries
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  validationRules: [
    createComplexityLimitRule(1000, {
      onCost: (cost) => console.log('Query cost:', cost),
    }),
  ],
});

// Field-level authorization
const resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      if (!context.user) {
        throw new GraphQLError('Not authenticated', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      // Only allow access to your own profile (or admin access)
      if (context.user.id !== id && context.user.role !== 'admin') {
        throw new GraphQLError('Not authorized', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
      return User.findById(id);
    },
  },
};

9. Use HTTPS and Enforce TLS Everywhere

All API traffic — including internal service-to-service communication — must be encrypted.

# Nginx — force HTTPS and set HSTS with preload
server {
    listen 80;
    server_name api.yourcompany.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.yourcompany.com;

    # HSTS — 2 years, include subdomains, preload
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # TLS 1.2+ only
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
}

For internal microservice communication, use mutual TLS (mTLS) where services authenticate each other with client certificates — not just the server to the client.


10. Add Security Headers to API Responses

# Flask — security headers for all API responses
from flask import Flask

app = Flask(__name__)

@app.after_request
def security_headers(response):
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
    response.headers['Pragma'] = 'no-cache'
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
    # Remove information-disclosing headers
    response.headers.pop('Server', None)
    response.headers.pop('X-Powered-By', None)
    return response

11. Log, Monitor, and Alert on API Activity

Without visibility into what your APIs are doing, you cannot detect attacks in progress or investigate incidents after the fact.

What to log:

  • Every authentication attempt (success and failure) with IP, user agent, timestamp
  • Authorization failures (who tried to access what they shouldn’t)
  • Rate limit triggers (potential brute force or scraping)
  • Any request to a sensitive endpoint (admin functions, bulk exports, password changes)

Do NOT log:

  • Passwords or authentication tokens
  • Full credit card numbers or other PAN data
  • Health record data covered by HIPAA
  • Any field that constitutes PII unless required for the specific log’s purpose
import structlog

logger = structlog.get_logger()

@app.before_request
def log_request():
    logger.info(
        "api_request",
        method=request.method,
        path=request.path,
        ip=request.remote_addr,
        user_id=getattr(current_user, 'id', None),
        # DO NOT log: request body (may contain passwords), full auth token
    )

@app.after_request
def log_response(response):
    if response.status_code >= 400:
        logger.warning(
            "api_error_response",
            status=response.status_code,
            path=request.path,
            ip=request.remote_addr,
        )
    return response

Alerting rules to configure:

  • 5+ authentication failures from the same IP in 5 minutes → potential brute force
  • Authentication success after 3+ failures → credential stuffing success
  • Rapid sequential enumeration of object IDs → BOLA scraping
  • Unusual response data volume → potential mass data extraction

API Security Checklist Summary

Use this checklist before deploying any API to production:

CategoryCheck
AuthenticationEvery protected endpoint requires a valid token or API key
AuthenticationJWT algorithm is explicitly specified — never none
AuthenticationToken expiry (exp) is validated and enforced
AuthorizationObject-level checks verify ownership on every resource request
AuthorizationAdmin/privileged endpoints have explicit role checks
Rate LimitingAuth endpoints limited to ≤10 attempts per 15 minutes per IP
Rate LimitingGeneral endpoints have a per-IP request limit
Input ValidationAll inputs validated for type, format, length, and range
Data ExposureResponse schemas are explicit — no raw model serialization
CORSAccess-Control-Allow-Origin is an explicit allowlist, not *
Mass AssignmentField allowlists prevent user-controlled sensitive field updates
TransportTLS 1.2+ enforced; HTTP redirects to HTTPS; HSTS set
Security HeadersX-Content-Type-Options, Cache-Control: no-store on all responses
LoggingAuth failures, authorization denials, and rate limit hits are logged
TestingBOLA tested by accessing resources with a different user’s session

How Offensive360 Tests API Security

Offensive360’s DAST scanner tests running APIs for all OWASP API Security Top 10 risks automatically:

  • BOLA/IDOR testing — parameterizes object IDs and tests cross-user access
  • Authentication bypass — tests for missing auth, JWT algorithm confusion, expired token acceptance
  • Injection testing — SQL injection, command injection, and NoSQL injection across all parameters
  • Rate limit verification — confirms rate limiting is enforced at the HTTP layer
  • CORS misconfiguration — tests origin reflection, wildcard with credentials
  • Security header checks — validates all required headers are present and correctly configured
  • Excessive data exposure — flags responses containing sensitive field patterns

The DAST scan runs against your live API (staging or production) and produces findings mapped to OWASP API Security, CWE, and PCI-DSS controls — with remediation guidance for each finding.

Run a one-time API security scan for $500 — results within 48 hours, no subscription required. Or book a demo to see a live scan of your API.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.