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:#fff7. 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"]
endThe 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.
#
- 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, andstatusmust 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.