
APIs are the backbone of modern applications. They power everything from mobile apps to microservices architectures. But with great power comes great responsibility—and unfortunately, great vulnerability.
After conducting security reviews on hundreds of APIs across various industries, I've noticed the same mistakes appearing repeatedly. This article documents the top 10, complete with examples and remediation strategies.
This is the #1 API vulnerability according to OWASP, and for good reason. It's everywhere.
GET /api/v1/users/12345/orders HTTP/1.1
Authorization: Bearer <user_token>
If user 12345's orders are returned regardless of which user's token is provided, you have BOLA.
# Vulnerable code
@app.route('/api/orders/<order_id>')
@require_auth
def get_order(order_id):
order = Order.query.get(order_id)
return jsonify(order.to_dict()) # No ownership check!
# Fixed code
@app.route('/api/orders/<order_id>')
@require_auth
def get_order(order_id):
order = Order.query.get(order_id)
if order.user_id != current_user.id:
abort(403)
return jsonify(order.to_dict())
APIs often return entire objects when the client only needs a few fields.
// Request: GET /api/users/me
// Response includes WAY too much:
{
"id": 12345,
"email": "user@example.com",
"password_hash": "$2b$12$...",
"ssn": "123-45-6789",
"internal_notes": "High-value customer",
"api_keys": ["sk_live_..."],
"role": "user",
"permissions": ["read", "write"]
}
Create response DTOs that only include necessary fields:
class UserPublicSchema(Schema):
id = fields.Int()
email = fields.Email()
name = fields.Str()
# Only public fields!
@app.route('/api/users/me')
@require_auth
def get_current_user():
return UserPublicSchema().dump(current_user)
Without rate limiting, APIs are vulnerable to:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app,
key_func=get_remote_address,
default_limits=["100 per minute"]
)
@app.route('/api/login', methods=['POST'])
@limiter.limit("5 per minute") # Stricter for auth endpoints
def login():
# Authentication logic
pass
@app.route('/api/password-reset', methods=['POST'])
@limiter.limit("3 per hour") # Very strict for sensitive operations
def password_reset():
# Reset logic
pass
Always inform clients of their limits:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640000000
I see these authentication mistakes constantly:
# WRONG - predictable tokens
token = base64.b64encode(f"{user_id}:{timestamp}".encode())
# RIGHT - cryptographically secure
token = secrets.token_urlsafe(32)
# JWT without expiration - DANGEROUS
jwt.encode({"user_id": 123}, SECRET)
# JWT with proper expiration
jwt.encode({
"user_id": 123,
"exp": datetime.utcnow() + timedelta(hours=1),
"iat": datetime.utcnow()
}, SECRET)
# WRONG - tokens in URL are logged everywhere
GET /api/data?token=abc123
# RIGHT - tokens in headers
GET /api/data
Authorization: Bearer abc123
Despite being well-known, SQL injection still appears in APIs:
@app.route('/api/search')
def search():
query = request.args.get('q')
# VULNERABLE!
results = db.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
return jsonify(results)
@app.route('/api/search')
def search():
query = request.args.get('q')
# Parameterized query - safe
results = db.execute(
"SELECT * FROM products WHERE name LIKE :query",
{"query": f"%{query}%"}
)
return jsonify(results)
Accepting all client-provided fields can lead to privilege escalation:
@app.route('/api/users/<id>', methods=['PUT'])
@require_auth
def update_user(id):
user = User.query.get(id)
# DANGEROUS - accepts ANY field from request
for key, value in request.json.items():
setattr(user, key, value) # Including 'role' and 'is_admin'!
db.session.commit()
ALLOWED_UPDATE_FIELDS = {'name', 'email', 'bio', 'avatar_url'}
@app.route('/api/users/<id>', methods=['PUT'])
@require_auth
def update_user(id):
user = User.query.get(id)
for key, value in request.json.items():
if key in ALLOWED_UPDATE_FIELDS:
setattr(user, key, value)
db.session.commit()
Error messages often leak sensitive information:
// Too detailed error response
{
"error": "DatabaseError",
"message": "Connection to postgres://admin:password123@db.internal:5432/prod failed",
"stack_trace": "..."
}
@app.errorhandler(Exception)
def handle_exception(e):
# Log the full error internally
app.logger.error(f"Unhandled exception: {e}", exc_info=True)
# Return generic message to client
return jsonify({
"error": "internal_server_error",
"message": "An unexpected error occurred"
}), 500
APIs often forget important security headers:
@app.after_request
def add_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Content-Security-Policy'] = "default-src 'none'"
response.headers['Cache-Control'] = 'no-store'
response.headers['Pragma'] = 'no-cache'
return response
Beyond BOLA, IDORs can appear in subtle ways:
# VULNERABLE
@app.route('/api/files/<filename>')
def get_file(filename):
return send_file(f'/uploads/{filename}') # Path traversal possible!
# FIXED
@app.route('/api/files/<filename>')
def get_file(filename):
# Validate filename
if '..' in filename or filename.startswith('/'):
abort(400)
safe_path = os.path.join('/uploads', secure_filename(filename))
if not safe_path.startswith('/uploads/'):
abort(400)
return send_file(safe_path)
APIs must validate all input rigorously:
from marshmallow import Schema, fields, validate, ValidationError
class CreateOrderSchema(Schema):
product_id = fields.Int(required=True, validate=validate.Range(min=1))
quantity = fields.Int(required=True, validate=validate.Range(min=1, max=100))
shipping_address = fields.Nested(AddressSchema, required=True)
notes = fields.Str(validate=validate.Length(max=500))
@app.route('/api/orders', methods=['POST'])
@require_auth
def create_order():
try:
data = CreateOrderSchema().load(request.json)
except ValidationError as e:
return jsonify({"errors": e.messages}), 400
# Process validated data
order = Order.create(**data)
return jsonify(order.to_dict()), 201
Before deploying any API, verify:
| Category | Check |
|---|---|
| Authentication | JWT properly validated, tokens expire |
| Authorization | Object-level access controls in place |
| Input | All input validated and sanitized |
| Output | No sensitive data leakage |
| Rate Limiting | Implemented on all endpoints |
| Logging | Security events logged (not sensitive data) |
| HTTPS | TLS 1.2+ required, HSTS enabled |
| Headers | Security headers configured |
API security isn't rocket science, but it requires diligence. The mistakes outlined here are preventable with proper awareness and secure coding practices.
My recommendation: Implement security reviews as part of your development process, not as an afterthought. Automated tools can catch many of these issues, but human review is essential for logic flaws like BOLA.
Want a security review of your API? Reach out through the contact page to discuss.