API Security #

APIs are the most attractive attack surface in modern applications — accessible from anywhere, easy to automate, and small design errors can expose millions of users’ data. What makes it more concerning: most API security incidents aren’t caused by sophisticated attacks, but by violations of basic principles that should already be standard. IDOR from missing ownership validation. Privilege escalation from unfiltered mass assignment. Sensitive data leaking in error responses. These aren’t technology failures — they’re discipline failures. This article covers the seven fundamental API security principles, how to apply them in mutually protecting layers, the most common attack patterns with concrete examples, and a checklist teams can use for self-audits.

The Seven Fundamental API Security Principles #

These principles aren’t abstract theory — every serious API security incident can be traced back to one (or several) of the following principles being violated.

1. Authentication Is Different from Authorization #

This is the confusion that most often causes serious vulnerabilities. Authentication answers “who are you?”, authorization answers “what are you allowed to do?”. Successfully passing authentication doesn’t automatically mean access to all resources.

// ANTI-PATTERN: Only checking authentication
func GetOrder(w http.ResponseWriter, r *http.Request) {
    user := getAuthenticatedUser(r) // checks the token is valid
    if user == nil {
        http.Error(w, "Unauthorized", 401)
        return
    }

    orderId := r.PathValue("id")
    order, _ := db.GetOrder(orderId)  // fetches directly without ownership check!
    json.NewEncoder(w).Encode(order)
}
// Logged-in User A can view orders belonging to User B

// CORRECT: Check authentication AND authorization (ownership)
func GetOrder(w http.ResponseWriter, r *http.Request) {
    user := getAuthenticatedUser(r)
    if user == nil {
        http.Error(w, "Unauthorized", 401)
        return
    }

    orderId := r.PathValue("id")
    order, _ := db.GetOrderByIdAndUserId(orderId, user.ID) // filter by user
    if order == nil {
        http.Error(w, "Not Found", 404)  // 404, not 403 (don't confirm the resource exists)
        return
    }
    json.NewEncoder(w).Encode(order)
}

2. Least Privilege — The Smallest Possible Access #

Every token, API key, service account, and user should only have the minimum access needed for their task. Granting excessive access is security debt that comes due when a breach happens.

// ANTI-PATTERN: Tokens with overly broad access
JWT payload: {
  "sub": "usr_123",
  "role": "admin",         // all admin permissions
  "scope": "all"           // access to every API
}
// If this token leaks: the attacker gets access to the entire system

// CORRECT: Tokens with specific scopes
Mobile app token: {
  "sub": "usr_123",
  "scope": "read:orders write:cart read:profile",
  "context": "mobile"     // can't be used in the admin panel
}
Admin panel token: {
  "sub": "usr_123",
  "scope": "admin:users admin:orders",
  "context": "admin",
  "ip_restriction": "office_subnet"
}

3. Default Deny — Deny First, Allow Explicitly #

All access is denied by default. New endpoints don’t automatically open — there must be an explicit declaration that this endpoint is accessible, by whom, and under what conditions.

// ANTI-PATTERN: Default allow, exceptions are blocked
router.Use(authMiddleware)  // but developers forget to add it to new routes

// CORRECT: All routes must explicitly declare their accessibility
// Use annotations or explicit middleware:

// Public endpoints (explicitly declared as public)
router.GET("/health", publicHandler)
router.GET("/v1/products", publicHandler)

// Authenticated endpoints
authRouter := router.Group("/v1", authMiddleware)
authRouter.GET("/orders", getOrders)
authRouter.POST("/orders", createOrder)

// Admin-only endpoints
adminRouter := router.Group("/v1/admin", authMiddleware, adminOnlyMiddleware)
adminRouter.GET("/users", listUsers)

4. Never Trust Client Input #

All client input — URL parameters, query strings, request bodies, even headers — is considered untrusted until validated. Clients must never determine their own role, permissions, user_id, or other sensitive data.

// ANTI-PATTERN: Trusting data from the client
func CreateOrder(r *http.Request) {
    var order Order
    json.NewDecoder(r.Body).Decode(&order)
    // order.UserID can be freely filled by the client!
    db.CreateOrder(order)
}

// CORRECT: Take sensitive data from the auth context, not the input
func CreateOrder(r *http.Request) {
    user := getAuthenticatedUser(r)  // from the token, not the body

    var input CreateOrderInput
    json.NewDecoder(r.Body).Decode(&input)
    // Validate input: only allowed fields

    order := Order{
        UserID: user.ID,  // from the auth token, not the request body
        Items:  input.Items,
        // the user can't set UserID, status, etc.
    }
    db.CreateOrder(order)
}

5. Explicit Ownership Validation #

Every resource access must have its ownership validated. It’s not enough to check that a resource exists — you must ensure that the resource actually belongs to the user accessing it.

// ANTI-PATTERN: No ownership validation (IDOR)
GET /api/invoices/12345
// The server only checks that invoice 12345 exists — not who owns it

// CORRECT: Ownership validation
SELECT * FROM invoices WHERE id = ? AND user_id = ?
//                                    ^^^^^^^^^^^ mandatory

// Or use row-level security in the database
// Or use scope-based queries in the service layer

6. Defense in Depth — Layered Defenses #

Don’t rely on a single security layer. If one layer is breached, the next must still prevent greater damage.

flowchart TD
    Internet["Internet / Attacker"]

    L1["Layer 1: Transport\nHTTPS / TLS\nHSTS Header"]
    L2["Layer 2: Rate Limiting & WAF\nBlock brute force\nDDoS mitigation"]
    L3["Layer 3: Authentication\nVerify tokens / API keys"]
    L4["Layer 4: Authorization\nValidate permissions & ownership"]
    L5["Layer 5: Input Validation\nSchema enforcement\nSanitization"]
    L6["Layer 6: Business Logic\nAnti-fraud rules\nRisk scoring"]
    L7["Layer 7: Data Layer\nRow-level security\nEncryption at rest"]
    L8["Layer 8: Monitoring & Alerting\nAnomaly detection\nAudit logging"]

    Internet --> L1
    L1 --> L2
    L2 --> L3
    L3 --> L4
    L4 --> L5
    L5 --> L6
    L6 --> L7
    L7 --> L8

    style L1 fill:#27AE60,color:#fff
    style L2 fill:#2ECC71,color:#fff
    style L3 fill:#F39C12,color:#fff
    style L4 fill:#E67E22,color:#fff
    style L5 fill:#E74C3C,color:#fff
    style L6 fill:#C0392B,color:#fff
    style L7 fill:#8E44AD,color:#fff
    style L8 fill:#2C3E50,color:#fff

7. Fail Securely #

When an error occurs, the system must fail in a way that doesn’t expose sensitive information. Error messages for clients must be generic — technical details only for internal logs.

// ANTI-PATTERN: Errors exposing internal details
HTTP 500 Internal Server Error
{
  "error": "SQLSTATE[42S02]: Table 'user_sessions_backup' doesn't exist",
  "query": "SELECT * FROM user_sessions_backup WHERE token = 'abc123'",
  "stack": "at database.go:145 in executeQuery..."
}
// Attackers learn: table names, query structure, code files and lines

// CORRECT: Generic errors to clients, details to internal logs
HTTP 500 Internal Server Error
{ "error": { "code": "INTERNAL_ERROR", "message": "An internal error occurred" } }

// Internal log (not sent to the client):
ERROR [2024-01-27 14:32:01] SQLSTATE[42S02]: Table 'user_sessions_backup' doesn't exist
      query: SELECT * FROM user_sessions_backup WHERE token = ?
      request_id: req_abc123
      user_id: usr_456

The OWASP API Security Top 10 #

OWASP publishes the list of the 10 most critical API security risks. Understanding and testing against this list is the security baseline every API should have.

flowchart LR
    subgraph OWASP["OWASP API Security Top 10 (2023)"]
        direction TB
        A1["API1: Broken Object\nLevel Authorization\n(IDOR)"]
        A2["API2: Broken\nAuthentication"]
        A3["API3: Broken Object\nProperty Level\nAuthorization"]
        A4["API4: Unrestricted\nResource Consumption"]
        A5["API5: Broken Function\nLevel Authorization"]
        A6["API6: Unrestricted\nAccess to Sensitive\nBusiness Flows"]
        A7["API7: Server-Side\nRequest Forgery"]
        A8["API8: Security\nMisconfiguration"]
        A9["API9: Improper\nInventory Management"]
        A10["API10: Unsafe\nConsumption of APIs"]
    end

The three most often found in production:

API1 — Broken Object Level Authorization (BOLA/IDOR): Endpoints that don’t validate whether the requested object actually belongs to the requesting user. Already covered under the Explicit Ownership Validation principle above.

API3 — Broken Object Property Level Authorization (Mass Assignment): Endpoints accepting client updates without filtering which fields may be changed. Users can change fields that shouldn’t be changeable, such as role, status, or is_admin.

API4 — Unrestricted Resource Consumption: No sufficient rate limiting, size limits, or pagination limits. This enables brute force, massive data scraping, or exhausting server resources.


Common Attack Patterns with Concrete Examples #

IDOR — Insecure Direct Object Reference #

Scenario:
  User A: ID usr_001
  User B: ID usr_002

  User A logs in and requests:
  GET /api/invoices/inv_456
  Authorization: Bearer ***

  The server only checks: is the token valid? Yes → return invoice inv_456
  The server does NOT check: does inv_456 belong to user_a?

  User A receives User B's invoice.

Impact:
  If there are 100,000 invoices with sequential or predictable IDs,
  one script can dump all users' invoice data within minutes.

Prevention:
  SELECT * FROM invoices WHERE id = ? AND user_id = ?
  — always include the ownership filter

Mass Assignment / Over-posting #

// User profile update endpoint:
PATCH /api/users/me
Authorization: Bearer ***
Body: {
  "name": "Hacker",
  "email": "[email protected]",
  "role": "admin",          ← users shouldn't be able to change this
  "is_verified": true,      ← users shouldn't be able to change this
  "subscription_plan": "enterprise"  ← users shouldn't be able to change this
}

// If the server maps the body directly to the model without a whitelist:
user.UpdateFromJSON(body)  // every field gets updated!

// Prevention: whitelist the changeable fields
type UpdateProfileInput struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    // role, is_verified, etc. NOT in this struct
}

Broken Function Level Authorization #

// ANTI-PATTERN: Admin endpoints not properly protected
GET /api/admin/users         → 403 Forbidden (correct, has middleware)
GET /api/admin/users/export  → 200 OK  (WRONG — forgot to add middleware)

// ANTI-PATTERN: Different HTTP methods not protected equally
GET  /api/orders/123  → auth check present
DELETE /api/orders/123 → auth check forgotten (new route, forgot to add)

// Prevention: use route groups with middleware
adminGroup := router.Group("/admin", adminAuthMiddleware)
adminGroup.GET("/users", listUsers)
adminGroup.GET("/users/export", exportUsers) // automatically protected

// Or: test every endpoint + HTTP method combination in a security test suite

Excessive Data Exposure #

// ANTI-PATTERN: Exposing every database field
GET /api/users/me
Response:
{
  "id": "usr_123",
  "name": "Budi",
  "email": "[email protected]",
  "password_hash": "$2b$10$...",     DANGEROUS
  "api_secret_key": "sk_live_...",   DANGEROUS
  "internal_notes": "...",           unnecessary
  "stripe_customer_id": "cus_...",   unnecessary
  "is_banned": false,                unnecessary
  "ban_reason": null                 unnecessary
}
// CORRECT: Only expose what's needed
{
  "id": "usr_123",
  "name": "Budi",
  "email": "[email protected]",
  "avatar_url": "...",
  "created_at": "2024-01-15T10:00:00Z"
}

Rate Limiting — More Than Just Anti-DDoS #

Rate limiting is often seen as only preventing DDoS, but it also protects against brute force, credential stuffing, and data scraping.

Rate limiting types that should exist:

1. Per IP — prevents attacks from a single source
   Limit: 100 requests/minute per IP
   Action: Return 429 with a Retry-After header

2. Per user/token — prevents abuse by valid accounts
   Limit: 1000 requests/hour per user
   Action: Return 429

3. Per sensitive endpoint — stricter for high-risk operations
   POST /auth/login: 5 requests/minute per IP
   POST /auth/forgot-password: 3 requests/minute per email
   Action: Exponential backoff or CAPTCHA

4. Per API key — for B2B APIs
   Every API key has a different quota per tier
   Expose X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers

The correct response when rate limited:
HTTP 429 Too Many Requests
{
  "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests" }
}
Headers:
  X-RateLimit-Limit: 100
  X-RateLimit-Remaining: 0
  X-RateLimit-Reset: 1706356000
  Retry-After: 47
Don’t rate limit only at the IP level — IPs are easily rotated using proxies or VPNs. Rate limiting is most effective when layered: per IP, per user, per endpoint, and combinations of these. Attackers with different IPs but the same token can still be detected with per-token rate limiting.

Input Validation and Schema Enforcement #

Input validation is the last defense before data touches business logic and the database.

Validation levels that should exist:

1. Format validation — data types, lengths, patterns
   email must be a valid email format
   phone must be numeric, 10-15 digits long
   date must be valid ISO 8601
   IDs must be UUIDs or a defined format

2. Business validation — domain rules
   quantity must not be negative
   start_date must be before end_date
   discount percentage must be between 0-100

3. Security validation
   Reject unknown fields (strict mode)
   Sanitize input to prevent injection
   Limit request body size

Example implementation in Go with struct validation:
type CreateOrderInput struct {
    Items    []OrderItem `json:"items" validate:"required,min=1,max=100"`
    Note     string      `json:"note" validate:"max=500"`
    // UserID not here — taken from the auth token
    // Status not here — automatically set to "pending"
}

// Reject unknown fields in the JSON decoder
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()  // extra fields = error

Logging and Audit Trails #

Logging is the API security component most often ignored until an incident occurs.

What should be logged for API security:

AUTHENTICATION EVENTS:
  ✓ Successful login: timestamp, user_id, IP, user-agent, device
  ✓ Failed login: timestamp, attempted email, IP, failure reason
  ✓ Token refresh: timestamp, user_id, IP
  ✓ Logout: timestamp, user_id
  ✓ Password reset request: timestamp, email, IP

AUTHORIZATION EVENTS:
  ✓ Access denied (403): timestamp, user_id, endpoint, method
  ✓ Sensitive resource access: timestamp, user_id, resource_id

DATA ACCESS EVENTS:
  ✓ Bulk data exports or downloads
  ✓ Access to other users' data (should be prevented, but if it slips through, record it)
  ✓ Sensitive data changes (role, permission, payment info)

What must NOT be logged:
  ✗ Passwords or password hashes
  ✗ JWT tokens or API keys (log only prefixes if needed)
  ✗ Credit card numbers (log only the last 4 digits)
  ✗ PII not needed for audits
Logs without alerting are logs that only help after an incident happens. Add alerting for suspicious events: many failed logins from one IP, high access-denied counts from one user, or unusual traffic at odd hours. Proactive alerts enable a response before major damage occurs.

Security Headers for APIs #

HTTP security headers are an easy-to-apply additional layer that’s often skipped for APIs.

Recommended headers for APIs:

Strict-Transport-Security: max-age=31536000; includeSubDomains
  → Force HTTPS, prevent downgrade attacks

X-Content-Type-Options: nosniff
  → Prevent MIME type sniffing

X-Frame-Options: DENY
  → Prevent clickjacking

Content-Security-Policy: default-src 'none'
  → For API responses that don't need resource loading

Referrer-Policy: no-referrer
  → Don't send the referrer header

Cache-Control: no-store
  → For responses containing sensitive data

X-Request-ID: <uuid>
  → For tracing and debugging (return it to clients so they can report it)

API Security Checklist #

AUTHENTICATION:
  □ All endpoints needing auth use auth middleware
  □ Tokens validated: signature, exp, iss, aud
  □ Tokens short-lived (≤60 minutes for access tokens)
  □ No static API keys without expiry in production
  □ Logout endpoint revokes tokens/sessions

AUTHORIZATION:
  □ Every resource access validates ownership (not just authentication)
  □ Admin-only endpoints protected with a separate permission check
  □ Authorization done server-side, not client-side
  □ No endpoints "forgotten" to have middleware added

INPUT VALIDATION:
  □ All input validated for format, type, and length
  □ Unknown fields rejected (strict schema validation)
  □ Sensitive fields (role, user_id, is_admin) can't be changed from the body
  □ Request body size limited

RATE LIMITING:
  □ Rate limiting on login and auth-related endpoints
  □ Rate limiting on expensive or data-heavy endpoints
  □ 429 responses include the Retry-After header

DATA EXPOSURE:
  □ Responses only contain fields the client needs
  □ No password hashes, tokens, or internal IDs in responses
  □ Generic error responses — no technical details or stack traces
  □ Pagination enforced — no endpoints returning all data

TRANSPORT SECURITY:
  □ HTTPS in all environments (not just production)
  □ Security headers configured
  □ Minimum TLS version 1.2 (1.3 is better)

LOGGING AND MONITORING:
  □ Auth events logged (login, logout, failed attempts)
  □ Authorization failures logged
  □ Sensitive data access logged
  □ Alerting for suspicious patterns in place

TESTING:
  □ Security tests in the CI pipeline
  □ IDOR tests: try accessing other users' resources
  □ Privilege escalation tests: try setting roles via the body
  □ Rate limit tests: verify 429 is correctly returned
  □ Error message tests: verify no internal details in responses

Summary #

  • Authentication and authorization are two different things — passing authentication doesn’t mean access to all resources. Every resource access must explicitly validate ownership.
  • Default deny isn’t paranoia, it’s a requirement — all new endpoints are closed by default. Open only what explicitly needs opening, not the other way around.
  • Client input can never be trusted — URL parameters, query strings, bodies, headers — all can be manipulated. User IDs, roles, and permissions must always come from the auth context, not the input.
  • IDOR is the most common and most easily prevented vulnerability — always include ownership filters in database queries. WHERE id = ? AND user_id = ? is a mandatory pattern.
  • Mass assignment is the gateway to privilege escalation — use whitelists of updatable fields, not blacklists. Fields like role, is_admin, and status must not exist in input structs.
  • Defense in depth — don’t rely on one layer. Auth middleware can be bypassed due to bugs, but row-level security in the database provides a second layer. Layer combinations make breaches far harder.
  • Fail securely — errors for clients must be generic — technical details, table names, stack traces, and internal information must never reach clients. Log details internally, send generic messages to clients.
  • Rate limiting is for more than just DDoS — layered rate limiting per IP, per user, and per endpoint protects against brute force, credential stuffing, and data scraping.
  • Log before you need it, not after — a good audit trail enables effective incident response. Without adequate logs, forensics becomes nearly impossible.
  • Security must exist from design, not be bolted on at the end — APIs designed with security principles from the start are far cheaper to secure than ones that must be retrofitted after production.
#

← Previous: GraphQL Federation   Next: Setup

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact