blogs
Building Secure APIs: Mistakes I've Made and How to Fix Them
Lessons from building and testing APIs in Java, Python, and TypeScript -- where API security actually breaks down and how to fix it.
Building Secure APIs: Mistakes I've Made and How to Fix Them
Every modern application is, at its core, an API. Whether you're building a mobile app, a web frontend, or a microservices architecture, APIs are how systems communicate. And APIs are frequently where security breaks down — not because developers don't care, but because API security requires deliberate thought at each step of the design and implementation process.
I've built APIs in Java during my internship, in Python for security tools, and in TypeScript for web applications. Here's what I've learned from both building and testing them.
The API Security Threat Landscape
The OWASP API Security Top 10 identifies the most critical API-specific risks. These differ from general web security in important ways:
- Broken Object Level Authorization (BOLA) — Most common API vulnerability. Accessing other users' objects by manipulating resource IDs.
- Broken Authentication — Weak tokens, missing token expiry, no rate limiting on auth endpoints.
- Broken Object Property Level Authorization — Exposing sensitive properties in responses, accepting properties that shouldn't be user-modifiable.
- Unrestricted Resource Consumption — No rate limiting, allowing DoS via expensive operations.
- Broken Function Level Authorization — Admin functions accessible to regular users.
Let me walk through the most impactful ones with concrete examples.
Broken Object Level Authorization (BOLA)
This is the most common API vulnerability I encounter. The API authenticates the user correctly but doesn't check whether that user is authorized to access the specific object they're requesting.
Vulnerable endpoint:
GET /api/orders/12345
Authorization: Bearer user_token_for_alice
If the order 12345 belongs to Bob, Alice shouldn't be able to see it. But a vulnerable API might just check "is this a valid token?" without checking "does this user own this order?"
Vulnerable Java implementation:
@GetMapping("/api/orders/{orderId}")
public Order getOrder(@PathVariable Long orderId) {
// Only checks if authenticated — not if authorized
return orderRepository.findById(orderId).orElseThrow();
}
Secure implementation:
@GetMapping("/api/orders/{orderId}")
public Order getOrder(@PathVariable Long orderId,
@AuthenticationPrincipal User currentUser) {
Order order = orderRepository.findById(orderId).orElseThrow();
// Verify ownership
if (!order.getUserId().equals(currentUser.getId())) {
throw new AccessDeniedException("Unauthorized access to order");
}
return order;
}
Even better — query with the user constraint baked in:
Order order = orderRepository.findByIdAndUserId(orderId, currentUser.getId())
.orElseThrow(() -> new AccessDeniedException("Order not found or unauthorized"));
This approach never returns an object the user doesn't own — the authorization check happens at the data layer.
Authentication Best Practices with JWT
JWT (JSON Web Tokens) are widely used for API authentication. They're convenient, stateless, and relatively easy to implement — which also means they're frequently implemented incorrectly.
Key mistakes I've seen:
1. Not verifying the algorithm:
// VULNERABLE — accepts "none" algorithm
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
// More secure — explicitly specify expected algorithm
Jwts.parserBuilder()
.setSigningKey(secret)
.requireAlgorithm("HS256") // or RS256 for asymmetric
.build()
.parseClaimsJws(token);
The "alg: none" attack exploits implementations that accept unsigned tokens if you specify none as the algorithm.
2. Storing JWTs in localStorage (for browser clients):
// VULNERABLE — localStorage accessible to any JS on the page (XSS risk)
localStorage.setItem('token', jwt);
// SAFER — httpOnly cookie, not accessible to JS
// Set by server: Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict
3. No token expiry:
String token = Jwts.builder()
.setSubject(user.getId())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1 hour
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
Short expiry times limit the window of exposure if a token is stolen. Combine with refresh tokens for a good UX without long-lived access tokens.
Rate Limiting and Throttling
APIs without rate limiting are vulnerable to brute force attacks, DoS, and abuse. I learned this lesson viscerally from my MQTT DoS lab — the same principles apply to HTTP APIs.
In Spring Boot with Bucket4j:
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String clientIp = request.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp, k ->
Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))))
.build()
);
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(429); // Too Many Requests
response.getWriter().write("Rate limit exceeded");
}
}
}
Apply stricter limits to authentication endpoints specifically:
- Login: 10 attempts per minute per IP
- Password reset: 5 requests per hour per email
Input Validation and Mass Assignment
APIs often accept JSON request bodies and map them directly to model objects. Without careful property filtering, an attacker can set fields they shouldn't be able to — like their own isAdmin flag or account balance.
Vulnerable (Spring Boot):
@PostMapping("/api/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
// If User has an isAdmin field, attackers can set it via the request
return userRepository.save(user);
}
Secure — use separate DTO:
public class UpdateUserDTO {
// Only include fields the user should be able to update
private String displayName;
private String bio;
// No isAdmin, no role, no accountBalance
}
@PostMapping("/api/users/{id}")
public User updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserDTO dto,
@AuthenticationPrincipal User currentUser) {
User user = userRepository.findById(id).orElseThrow();
if (!user.getId().equals(currentUser.getId())) {
throw new AccessDeniedException("Cannot modify another user's profile");
}
user.setDisplayName(dto.getDisplayName());
user.setBio(dto.getBio());
return userRepository.save(user);
}
Separate DTOs for input (request) and output (response) are a clean architectural pattern that naturally prevents mass assignment.
HTTPS and Security Headers
Every API should be served over HTTPS. No exceptions. But beyond HTTPS, HTTP response headers configure browser security behaviors:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'self'
X-XSS-Protection: 1; mode=block
In Spring Boot:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.frameOptions().deny()
.contentTypeOptions().and()
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
);
return http.build();
}
}
Logging API Security Events
Every significant security event in your API should be logged: authentication failures, authorization denials, invalid input, rate limit hits. Without this, you're flying blind.
What I log:
- Authentication success and failure (with source IP, user agent)
- Authorization denials (resource type, requester ID, resource owner ID)
- Input validation failures (sanitized — no raw user input in logs)
- Rate limit triggers
- Any admin operation
These logs feed into a SIEM for correlation and alerting.
Testing Your APIs
Once built, test your APIs with security specifically in mind:
Manual testing with Burp Suite: Intercept API requests, modify object IDs, remove authentication headers, inject SQL payloads — systematically test every OWASP API Top 10 scenario.
Automated scanning: OWASP ZAP has an API scanner. Postman has basic security tests. For more thorough automated API security testing, tools like 42crunch or APIsec provide dedicated API scanning.
Bounty Hawk's API module (in progress): I'm extending my automation tool to cover REST and GraphQL API testing specifically.
The best time to find these vulnerabilities is during development — before they're in production, before attackers find them first.
