Secure API Design Principles and Best Practices
Security built into an API from the beginning costs a fraction of security retrofitted after deployment. Secure design means making deliberate decisions about how the API is structured, what it accepts, what it returns, and how it communicates — before writing the first line of code.
Principle 1: Least Privilege by Default
Every access decision should grant the minimum permission needed.
For Tokens:
A mobile app that displays user orders should receive a token
scoped to orders:read only.
It should NOT receive a token with orders:write or users:admin.
If the app is compromised, the attacker can only read orders —
not modify them, delete users, or access admin functions.
For Service Accounts:
The API's database connection should use a user with only
SELECT on the tables it needs.
A read-only reporting service should not hold INSERT, UPDATE,
DELETE, or DROP permissions.
For Endpoints:
New endpoints should be inaccessible by default.
Access is explicitly granted to specific roles — not denied to others.
Deny all, permit specific.
Design pattern — Token Scope Definition:
For each API operation, define the minimum required scope:
GET /api/orders → Requires: orders:read
POST /api/orders → Requires: orders:write
DELETE /api/orders/{id} → Requires: orders:delete (admin only)
GET /api/admin/users → Requires: admin:users:read
Document these requirements in the OpenAPI specification.
Principle 2: Design for Explicit Data Contracts
Every API endpoint should have a clearly defined:
- Input schema (what it accepts)
- Output schema (what it returns)
- Error responses (what it returns on failure)
Using OpenAPI 3.0 Specification:
paths:
/api/users/{userId}:
get:
summary: Get user profile
security:
- bearerAuth: []
parameters:
- name: userId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserProfile'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
UserProfile:
type: object
properties:
id: { type: integer }
name: { type: string }
email: { type: string, format: email }
# password, role, internal_score NOT defined here
# → cannot appear in this response
The schema is the contract.
Enforce it with middleware that validates all requests and responses.
Any deviation = automatic rejection.
Principle 3: Fail Securely
When something goes wrong, the system should fail in a way that does not expose sensitive information or grant access. Fail-Open (Dangerous): Authorization check fails due to database timeout. System assumes: "Unable to determine permission → allow access." Result: During any DB outage, all authorization checks pass. Attacker causes DB unavailability → accesses everything. Fail-Closed (Secure): Authorization check fails due to database timeout. System assumes: "Unable to verify permission → deny access." Result: Some legitimate users temporarily lose access during outage. Trade-off: Availability during failure vs security. → Security must win. Error handling design rules: - Default return value from any function that checks permissions: DENY. - If the identity provider is unreachable: reject all protected requests. - If the token cannot be validated (expired, wrong signature): deny. - If the database is unavailable for authorization check: deny. - Log every denial-due-to-system-failure with HIGH severity.
Principle 4: Defense in Depth
No single control is sufficient. Layer multiple independent controls. Example — Protecting a payment endpoint: Layer 1: Network — TLS 1.3, no HTTP allowed Layer 2: Gateway — IP allowlisting, rate limiting (10/min) Layer 3: Authentication — JWT validation, expiry check Layer 4: Authorization — User owns this payment account Layer 5: Input validation — Amount is positive float, currency is valid code Layer 6: Business logic — Amount does not exceed account balance Layer 7: Idempotency key — Duplicate requests blocked (same key = same result) Layer 8: Audit log — All payment operations recorded immutably Layer 9: Anomaly detection — Unusual payment patterns trigger alert An attacker must defeat ALL nine layers to succeed. A bug in layer 3 does not grant full access if layers 4-9 still hold.
Principle 5: Idempotency for State-Changing Operations
Idempotency means that making the same request multiple times
produces the same result as making it once.
Problem without idempotency:
Client sends: POST /api/payments { amount: 5000 }
Network times out. Client unsure if payment was processed.
Client retries: POST /api/payments { amount: 5000 }
Payment processed twice. Customer charged ₹10,000 instead of ₹5,000.
Solution — Idempotency Keys:
Client generates unique key: idempotency_key = "uuid4-abc123"
First request:
POST /api/payments
Idempotency-Key: uuid4-abc123
{ "amount": 5000 }
→ Server processes payment, stores result linked to key "uuid4-abc123"
→ Returns: 200 OK, transaction_id: "txn_9901"
Retry request (same key):
POST /api/payments
Idempotency-Key: uuid4-abc123
{ "amount": 5000 }
→ Server finds key "uuid4-abc123" in cache
→ Returns SAME result: 200 OK, transaction_id: "txn_9901"
→ Payment NOT processed again.
Security benefit:
Idempotency keys also prevent replay attacks.
If an attacker replays a valid payment request with the same key,
the server detects the duplicate and returns the cached result
without processing a second transaction.
Principle 6: Versioning with Security in Mind
API versioning allows gradual evolution without breaking existing clients.
It also creates security responsibilities.
Versioning strategies:
URL versioning: /api/v1/users, /api/v2/users
Header versioning: API-Version: 2
Query parameter: /api/users?version=2
Security rules for versioning:
1. Maintain security patches on ALL active versions.
A CVE in the authentication library affects v1 and v2 equally.
2. Deprecate and remove old versions on a published schedule.
Every active version is an attack surface.
Fewer versions = smaller attack surface.
3. Do not add security features only to new versions.
If rate limiting is added in v2, backport it to v1.
Attackers use the version without the protection.
4. Monitor usage of deprecated versions.
Send deprecation warnings in response headers.
API-Deprecation: true
Sunset: Sat, 01 Mar 2025 00:00:00 GMT
Principle 7: Avoid Exposing Internal Implementation Details
API design should hide the underlying technology stack.
What to hide:
Server software version:
Wrong: Server: Apache/2.4.51 (Ubuntu) ← Tells attacker exact version
Right: Server: (header removed entirely)
Framework and language:
Wrong: X-Powered-By: PHP/8.1.0
Right: X-Powered-By: (header removed)
Database structure in error messages:
Wrong: "column 'internal_score' not found in table 'users_v2'"
Right: "An error occurred. Request ID: req_abc123"
Stack traces in responses:
Wrong: com.company.api.UserService.getUserById(UserService.java:142)
Right: "An unexpected error occurred."
Sequential IDs:
Wrong: /api/orders/1001, /api/orders/1002 (tells attacker order volume)
Right: /api/orders/uuid4-generated-id (reveals nothing)
Error messages that confirm account existence:
Wrong: "No account found for email: user@example.com"
Right: "If an account exists with that email, a reset link has been sent."
(Do not confirm whether the email exists)
Principle 8: Consistent Security Across All HTTP Methods
The same URL often handles multiple HTTP methods.
Each method must have its own security validation.
Common oversight:
GET /api/users/101 → Auth check: ✓ Ownership check: ✓
PUT /api/users/101 → Auth check: ✓ Ownership check: ✓
DELETE /api/users/101 → Auth check: ✓ Ownership check: ✗ ← BUG
Security matrix — document and test every combination:
Endpoint GET POST PUT PATCH DELETE
/api/orders/{id}
Auth required? Y Y Y Y Y
Role required? any any owner owner admin
Ownership check? Y N/A Y Y Y
Rate limit? 100/m 10/m 10/m 10/m 5/m
Principle 9: Secure Defaults
Configuration should be secure out of the box. Security should not depend on administrators making the right choices. Insecure defaults (what not to do): CORS: Access-Control-Allow-Origin: * (default: allow all) → Should default to: deny all, explicit allowlist required New user roles: default = "admin" → Should default to: least privileged role available Token expiry: no expiry set by default → Should default to: 1 hour for access tokens Rate limiting: disabled by default, must be enabled → Should default to: enabled with conservative limits Logging: off by default → Should default to: logging enabled, sensitive fields masked HTTPS enforcement: optional → Should default to: required, HTTP rejected Secure defaults means: the system is secure if you change nothing. It requires deliberate action to make it less secure.
Principle 10: Document Security Requirements Explicitly
Every API endpoint's security requirements should be documented:
In the OpenAPI spec:
security:
- bearerAuth: [orders:read] ← Which scope is required
x-rate-limit: 100/minute ← Rate limit
x-data-classification: PII ← Data sensitivity level
x-audit-required: true ← Must be audit logged
In code comments:
/**
* GET /api/users/{id}
* Authentication: Required (Bearer token)
* Authorization: User must own resource OR have admin role
* Data returned: PUBLIC profile only (no PII beyond name+avatar)
* Rate limit: 60/minute per user
*/
Documentation benefits:
Developers know exactly what security to implement.
Testers know exactly what to verify.
Auditors can verify compliance without reading all code.
Key Points
- Build security into API design before writing code — retrofitting is far more expensive.
- Apply least privilege at every layer: token scopes, database permissions, and endpoint access all default to minimum required.
- Define explicit data contracts using OpenAPI schemas. Validate inputs against the schema and filter outputs through defined response models.
- Fail closed: when authorization checks fail due to system errors, deny access rather than allow it.
- Idempotency keys prevent double-processing and replay attacks on state-changing operations.
- Secure defaults mean the system is safe if nothing is changed. Making it less secure should require deliberate action.
