API Security Hardening: OWASP Top 10 Implementation

Secure your APIs against the OWASP API Security Top 10. Covers authentication, authorization, rate limiting, input validation, and security testing with practical code examples.

API Security Hardening: OWASP Top 10 Implementation

This reference page outlines the essential practices for securing APIs against the most critical threats identified by OWASP, using real, repeatable, and production-proven techniques. It is intended for engineers responsible for designing, implementing, and maintaining secure APIs in any environment—whether on-prem, cloud, or hybrid. The guidance applies when launching new services, conducting security audits, or responding to breaches. Each step is grounded in HTTP, authentication, configuration, and observability, with no assumptions about specific platforms.

Secure API Endpoints Against Injection (A01:2023)

Injection attacks—SQL, NoSQL, OS, and LDAP—are the most common way APIs are compromised. To harden against them, implement parameterized queries with explicit input validation.

For a REST API built with Node.js and PostgreSQL, use pg client with PreparedStatement:

const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

const query = {
  text: 'SELECT * FROM users WHERE email = $1 AND status = $2',
  values: [email, status],
};

const result = await client.query(query);

The text field defines the SQL template; $1, $2 are positional placeholders. The database parses and compiles the query once, binding values at runtime. This prevents SQL injection even if email contains ' OR '1'='1.

In addition, validate inputs using JSON Schema in middleware:

{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "maxLength": 254
    },
    "status": {
      "type": "string",
      "enum": ["active", "inactive", "pending"]
    }
  },
  "required": ["email"]
}

Fail fast: return 400 Bad Request with a structured error body when validation fails:

{
  "error": "Validation failed",
  "details": [
    {
      "field": "email",
      "message": "Invalid email format",
      "value": "user@domain"
    },
    {
      "field": "status",
      "message": "Must be one of: active, inactive, pending",
      "value": "active"
    }
  ]
}

Ensure every input—query parameters, headers, path variables, and request body—is validated against a schema before being used in a query or command.

Protect Against Broken Object Level Access (BOLA)

BOLA occurs when APIs expose data access through object IDs (e.g., GET /users/123), but clients can access any user, even those they should not.

Enforce object-level access using a combination of identity, authorization, and data scoping.

In a typical API, every request includes a X-User-ID header from the authentication system:

GET /api/v1/users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-User-ID: 456

The backend service retrieves the user ID from the JWT payload and applies a filter:

const userId = jwtPayload.sub;
const requestedUserId = parseInt(req.params.id);

if (userId !== requestedUserId) {
  const user = await db.query('SELECT * FROM users WHERE id = $1', [requestedUserId]);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  const isOwner = await db.query(
    'SELECT 1 FROM user_roles WHERE user_id = $1 AND role = $2',
    [userId, 'owner']
  );

  if (!isOwner && user.owner_id !== userId) {
    return res.status(403).json({ error: 'Access denied' });
  }
}

This ensures only the user or an owner can access their own data. For scalability, cache user roles and ownership in Redis:

GET /api/v1/users/123
> X-User-ID: 456
> Authorization: Bearer <token>

Redis:
GET user:456:roles
> ["admin", "owner"]

A common failure mode: forgetting to validate X-User-ID against the JWT sub claim. If the header is set but the JWT sub is different, a client can access any user’s data by forging the header.

Implement Strict Authentication and Token Management

Use JWTs with short-lived access tokens and refresh tokens. Configure the issuer, signing algorithm, and expiration.

In a Go service using golang-jwt/jwt:

func GenerateToken(userID int) (string, error) {
  claims := jwt.MapClaims{
    "sub": userID,
    "iat": time.Now().Unix(),
    "exp": time.Now().Add(time.Hour * 2).Unix(),
    "iss": "https://auth.example.com",
    "aud": "https://api.example.com",
  }

  token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
  return token.SignedString([]byte(os.Getenv("JWT_SECRET")))
}

The iss (issuer) and aud (audience) fields ensure the token is valid for the API. The exp field specifies expiration, and iat (issued at) helps in logging and audit trails.

Clients must include the token in the Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQ1NiwiaWF0IjoxNjMxODI4MDAwLCJleHAiOjE2MzE4MzE2MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIn0.r9t7r5TqzJt3oJ2YRl3n9Xa8e5v3c7vZs2k1x3KcDvM

The API validates the signature, checks iss, aud, exp, and iat, and extracts sub.

A subtle failure: using jwt.Parse without validating exp and aud. A token from auth.example.com is accepted by api.example.com even when aud does not match.

For refresh tokens, issue a second token valid for 7 days:

{
  "refresh_token": "rt-1234567890abcdef",
  "expires_in": 604800,
  "access_token": "at-9876543210fedcba",
  "token_type": "Bearer"
}

The client rotates the access token every 2 hours, revalidating it against the refresh token.

Harden API Response and Request Size Limits

APIs are vulnerable to resource exhaustion through oversized payloads. Set strict limits on request and response size.

In NGINX, configure request body size and client body buffer:

http {
  client_max_body_size 10M;
  client_body_buffer_size 1M;

  server {
    listen 80;
    location /api/v1 {
      proxy_pass http://backend;
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_buffering on;
      proxy_buffer_size 128k;
      proxy_buffers 4 256k;
      proxy_busy_buffers_size 256k;
    }
  }
}

client_max_body_size limits the total request body to 10MB. If the client sends more, NGINX returns 413 Payload Too Large.

The backend service must also validate the incoming JSON body:

app.use(express.json({ limit: '10mb' }));

For response size, use compression:

location /api/v1 {
  gzip on;
  gzip_types application/json application/xml text/html text/css;
  gzip_min_length 1024;
  gzip_comp_level 6;
}

Responses are compressed before sending. Clients must accept gzip via Accept-Encoding: gzip.

A common oversight: enabling gzip but not setting Content-Encoding: gzip in the response headers. Clients assume compressed data but receive raw JSON. The failure mode: large JSON payloads take 3x longer to parse.

Enable Comprehensive Logging and Monitoring

Log every API request with structured metadata for observability and attack detection.

Use JSON logs with a consistent schema:

{
  "timestamp": "2023-09-15T12:34:56.789Z",
  "level": "info",
  "service": "api-user",
  "request_id": "req-abc123",
  "method": "GET",
  "path": "/api/v1/users/123",
  "status": 200,
  "duration_ms": 124,
  "user_id": 456,
  "client_ip": "192.168.1.100",
  "headers": {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
    "Authorization": "Bearer <token>"
  },
  "request_body": {
    "query": "?include=orders&limit=50"
  },
  "response_body": {
    "id": 123,
    "name": "Alice",
    "email": "[email protected]"
  }
}

Log to stdout or a file, and forward via fluentd or journalctl.

Monitor for anomalies: high error rates, slow requests, and unusual request patterns.

Set alerting thresholds:

Use curl to test endpoints and validate responses:

curl -X GET \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-User-ID: 456" \
  -H "Accept: application/json" \
  "https://api.example.com/api/v1/users/123" \
  -v

The -v flag shows headers, request body, and response, helping diagnose issues.

Enforce Rate Limiting and Throttling

Prevent abuse and denial-of-service attacks by limiting request frequency.

Implement per-client rate limiting using Redis with redis-py:

import redis
import time

r = redis.Redis(host='redis.example.com', port=6379, db=0)

def rate_limit(client_id: str, limit: int = 100, window: int = 3600):
    now = time.time()
    key = f"rate_limit:{client_id}"

    # Add current timestamp to Redis sorted set
    r.zadd(key, {now: now})
    # Remove timestamps older than window seconds
    r.zremrangebyscore(key, 0, now - window)
    # Get count of requests in window
    count = r.zcount(key, now - window, now)

    if count > limit:
        return False, f"Rate limit exceeded: {count}/{limit} requests in {window}s"
    return True, "OK"

Apply this in middleware before processing each request. Return 429 Too Many Requests when exceeded:

{
  "error": "Rate limit exceeded",
  "retry_after": 3600,
  "limit": 100,
  "remaining": 98,
  "reset": "2023-09-15T13:34:56Z"
}

The retry_after header tells clients when to retry, and reset provides a time when the window resets.

A sharp edge: forgetting to set X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Clients expect these for efficient throttling.

Validate and Secure API Contracts

Use OpenAPI (Swagger) to define and enforce API contracts. Validate requests and responses against the schema.

Define a openapi.yaml file:

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0
paths:
  /users/{id}:
    get:
      summary: Get user by ID
      operationId: getUserById
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          description: User not found
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email

Validate incoming requests using openapi-validator middleware:

const { validateRequest } = require('openapi-validator');

app.use(validateRequest({ openapi: 'openapi.yaml' }));

When a request does not match the schema, return 400 with a detailed error:

{
  "error": "Invalid request body",
  "details": [
    {
      "field": "email",
      "message": "Must be a valid email address",
      "value": "user@domain"
    },
    {
      "field": "name",
      "message": "String value required",
      "value": null
    }
  ]
}

The contract becomes the source of truth. Any change to the API must be tested against this schema, and clients must validate against it.

Apply Security Headers and Response Hardening

Add security headers to all API responses:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

These headers mitigate clickjacking, XSS, and CSRF attacks. X-Content-Type-Options: nosniff prevents MIME type sniffing, ensuring content is served as intended.

For JSON responses, ensure Content-Type: application/json; charset=utf-8 and include Link headers for pagination:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Link: <https://api.example.com/api/v1/users?page=2>; rel="next"
Link: <https://api.example.com/api/v1/users?page=10>; rel="last"
X-Total-Count: 42

The Link header enables clients to navigate paginated results without parsing the response body.

A silent failure: missing charset=utf-8. Clients assume UTF-8 but receive Latin-1-encoded JSON, leading to garbled text.

This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.