API Security
APIs are the #1 attack surface for modern applications. They expose business logic and data directly.
OWASP API Security Top 10 (2023)
| # | Risk | Description |
|---|---|---|
| API1 | Broken Object Level Authorization | Access other users' resources via ID manipulation (IDOR) |
| API2 | Broken Authentication | Weak or missing auth tokens |
| API3 | Broken Object Property Level Auth | Read/write fields you shouldn't have access to |
| API4 | Unrestricted Resource Consumption | No rate limiting β memory/CPU exhaustion |
| API5 | Broken Function Level Authorization | Regular user calls admin endpoints |
| API6 | Unrestricted Access to Sensitive Flows | Bots abuse legitimate flows (bulk buying) |
| API7 | Server-Side Request Forgery | API fetches attacker-controlled URL |
| API8 | Security Misconfiguration | Defaults, verbose errors, no HTTPS |
| API9 | Improper Inventory Management | Forgotten, undocumented APIs with no auth |
| API10 | Unsafe Consumption of APIs | Trusting third-party API data without validation |
BOLA (API1) vs BFLA (API5):
- BOLA β you can access a resource you shouldn't (another user's order)
- BFLA β you can call a function you shouldn't (admin endpoint as regular user)
Input Validation
@Data
public class CreateUserRequest {
@NotBlank
@Size(min = 2, max = 100)
@Pattern(regexp = "^[a-zA-Z\\s'-]+$", message = "Name contains invalid characters")
private String name;
@NotBlank
@Email
@Size(max = 254) // RFC 5321 max
private String email;
@NotBlank
@Size(min = 12, max = 128)
private String password;
@Min(0) @Max(150)
private Integer age;
}
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest req) {
return ResponseEntity.status(201).body(userService.create(req));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> new FieldError(e.getField(), e.getDefaultMessage()))
.collect(toList());
return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
}
File Upload Validation
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam MultipartFile file) {
if (file.getSize() > 10 * 1024 * 1024) { // 10 MB
throw new BadRequestException("File too large");
}
// Validate content type via MIME sniffing (not just extension)
String detectedType = tika.detect(file.getInputStream());
if (!Set.of("image/jpeg", "image/png", "application/pdf").contains(detectedType)) {
throw new BadRequestException("File type not allowed");
}
// Rename file β NEVER trust original filename (path traversal risk)
String safeFilename = UUID.randomUUID() + getExtension(detectedType);
// Store OUTSIDE web root
Path destination = storageRoot.resolve(safeFilename).normalize();
if (!destination.startsWith(storageRoot)) {
throw new SecurityException("Path traversal detected");
}
Files.copy(file.getInputStream(), destination);
return ResponseEntity.ok(safeFilename);
}
Mass Assignment Prevention
// β Vulnerable β attacker sends: { "role": "ADMIN", "creditBalance": 99999 }
@PutMapping("/users/{id}")
public User update(@PathVariable Long id, @RequestBody User user) {
return userRepository.save(user); // Overwrites ALL fields including role!
}
// β
Explicit DTOs β only include fields that should be updatable
@Data
public class UpdateProfileRequest {
@Size(max = 100) private String name;
@Size(max = 500) private String bio;
// NO role, NO creditBalance, NO admin flag
}
@PutMapping("/users/{id}")
public UserResponse update(@PathVariable Long id,
@Valid @RequestBody UpdateProfileRequest req,
@AuthenticationPrincipal UserDetails principal) {
User user = userRepository.findById(id).orElseThrow();
verifyOwnership(user, principal);
user.setName(req.getName());
user.setBio(req.getBio());
// Role is NOT updated β it's not in the DTO
return mapper.toResponse(userRepository.save(user));
}
Sensitive Data in API Responses
// β Returns passwordHash, SSN, internalFlags, etc.
public User getUser(Long id) {
return userRepository.findById(id);
}
// β
Explicit response DTO β only safe fields
@Data
public class UserPublicResponse {
private Long id;
private String name;
private String avatarUrl;
// NO email (unless authorized), NO password hash, NO SSN
}
// β
Jackson annotations to never serialize sensitive fields
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Accepted in requests, never in responses
@JsonIgnore
private String internalApiKey;
API Keys
@Service
public class ApiKeyService {
public ApiKey generate(Long userId) {
byte[] keyBytes = new byte[32];
new SecureRandom().nextBytes(keyBytes);
String rawKey = "sk_live_" + Base64.getUrlEncoder()
.withoutPadding().encodeToString(keyBytes);
// Store HASHED β never store raw key in DB
String keyHash = DigestUtils.sha256Hex(rawKey);
apiKeyRepository.save(new ApiKey(userId, keyHash));
// Return raw key to user ONCE β cannot be retrieved again
return new ApiKeyCreationResponse(rawKey);
}
public Optional<Long> authenticate(String rawKey) {
String hash = DigestUtils.sha256Hex(rawKey);
return apiKeyRepository.findByKeyHash(hash)
.filter(k -> !k.isRevoked())
.map(ApiKey::getUserId);
}
}
Rate Limiting
@Component
public class MultiTierRateLimiter {
// Tier 1: Per-IP β anti-scraping
public boolean checkIpLimit(String ip) {
Bucket bucket = getOrCreate("ip:" + ip,
Bandwidth.classic(1000, Refill.greedy(1000, Duration.ofHours(1))));
return bucket.tryConsume(1);
}
// Tier 2: Per-User by subscription tier
public boolean checkUserLimit(Long userId, ApiTier tier) {
int limit = switch (tier) {
case FREE -> 100;
case BASIC -> 1_000;
case PRO -> 10_000;
case ENTERPRISE -> 100_000;
};
return getOrCreate("user:" + userId,
Bandwidth.classic(limit, Refill.greedy(limit, Duration.ofHours(1))))
.tryConsume(1);
}
// Tier 3: Per-Endpoint β expensive operations get stricter limits
public boolean checkEndpointLimit(String userId, String endpoint) {
Map<String, Integer> endpointLimits = Map.of(
"/api/export", 10,
"/api/report", 5
);
int limit = endpointLimits.getOrDefault(endpoint, 1000);
return getOrCreate("endpoint:" + userId + ":" + endpoint,
Bandwidth.classic(limit, Refill.greedy(limit, Duration.ofHours(1))))
.tryConsume(1);
}
}
Request Signing (High-Security APIs)
Used when you need proof that a specific client sent a specific request at a specific time. Prevents replay attacks and tampering.
// HMAC-based request signing (similar to AWS Signature v4)
public class RequestSigner {
public void signRequest(HttpRequest request, String apiKey, String secretKey) {
String timestamp = Instant.now().toString();
String nonce = UUID.randomUUID().toString();
String canonicalRequest = request.getMethod() + "\n"
+ request.getUri().getPath() + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ sha256Hex(readBody(request));
String signature = hmacSha256(secretKey, canonicalRequest);
request.addHeader("X-Api-Key", apiKey);
request.addHeader("X-Timestamp", timestamp);
request.addHeader("X-Nonce", nonce);
request.addHeader("X-Signature", "v1=" + signature);
}
}
// Server-side verification
public void verifySignature(HttpRequest req) {
String timestamp = req.getHeader("X-Timestamp");
// Reject requests older than 5 minutes β replay protection
if (Instant.parse(timestamp).isBefore(Instant.now().minus(5, MINUTES))) {
throw new SecurityException("Request expired");
}
// Check nonce hasn't been used before (store in Redis with TTL)
String nonce = req.getHeader("X-Nonce");
if (Boolean.TRUE.equals(redis.hasKey("used_nonce:" + nonce))) {
throw new SecurityException("Nonce already used β replay detected");
}
redis.opsForValue().set("used_nonce:" + nonce, "1", Duration.ofMinutes(10));
// Verify HMAC ...
}
GraphQL Security
Introspection (Disable in Production)
@Bean
public GraphQlSource graphQlSource() {
return GraphQlSource.schemaResourceBuilder()
.configureRuntimeWiring(wiring -> wiring
.fieldVisibility(isProduction
? NoIntrospectionGraphqlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITY
: DEFAULT_FIELD_VISIBILITY)
)
.build();
}
Query Depth & Complexity Limiting
// Prevent deeply nested queries: { user { friends { friends { friends { ... }}}}}
@Bean
public Instrumentation queryDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(10);
}
@Bean
public Instrumentation queryComplexityInstrumentation() {
return new MaxQueryComplexityInstrumentation(200);
}
Interview Questions
Q1: What is the OWASP API Security Top 10? Describe API1, API4, and API5.
The OWASP API Security Top 10 identifies the most critical security vulnerabilities affecting web APIs:
- API1: Broken Object Level Authorization (BOLA / IDOR): An API endpoint exposes internal object IDs without validating if the current user has permission to access that specific object (e.g. accessing
/api/accounts/123when you own account 456).- API4: Unrestricted Resource Consumption: The API lacks limits on execution resources (e.g., rate limits, file sizes, or memory), permitting Denial of Service (DoS) attacks.
- API5: Broken Function Level Authorization (BFLA): The API fails to restrict administrative URLs or privilege functions to authorized roles (e.g. standard user accessing
/api/admin/users/delete).
Q2: What is mass assignment and how do you prevent it in Spring Boot?
Mass Assignment (API6: Unrestricted Endpoint Access / Over-posting): Occurs when an application binds incoming client request properties (e.g., JSON fields) directly to database entity models. An attacker sends extra properties (e.g.
{"isAdmin": true}or{"balance": 9999}) that get saved automatically to the database. Prevention in Spring Boot:
- Use distinct DTOs (Data Transfer Objects): Never bind request parameters directly to JPA
@Entityclasses. Declare a specific record or class for inputs containing only the fields allowed to be updated.- Specify binding annotations: Use
@JsonIgnoreor validation controls on sensitive entity fields.
Q3: How do you validate file uploads securely? What is path traversal?
Secure File Validation:
- Filename Sanitization: Remove path characters (
../or\) to prevent directory traversal. Generate a random UUID for the physical storage filename.- MIME-Type & Magic Byte Check: Never trust the
Content-Typeheader sent by the browser. Inspect the file's first few bytes (magic numbers) to verify the file signature matches the extension.- Non-executable Storage: Store files in external object storage (AWS S3) outside the application web root, and serve them with download headers (
Content-Disposition: attachment). Path Traversal: An attack where an attacker inserts sequence path steps (like../../etc/passwd) in file input parameters to access arbitrary system directories on the server.
Q4: Why should API keys be stored hashed, not in plaintext?
API keys are equivalent to passwords. If a database is leaked, compromised, or backed up unsafely, plaintext keys allow attackers to instantly authenticate as those clients. Secure Design: When issuing an API key, present it to the client only once. Store only the cryptographically hashed version (using a fast hashing algorithm like SHA-256 or bcrypt) in the database. When verifying requests, hash the incoming key header and match it against the database hash.
Q5: What are the unique security concerns of GraphQL APIs compared to REST?
GraphQL introduces a single endpoint (typically
/graphql) where clients request custom queries, creating specific security vulnerabilities:
- Deep Query Nesting (DoS): Attackers request circular relations (e.g.,
user { friends { friends { friends } } }) to exhaust server CPU/memory. (Mitigation: enforceMaxQueryDepth).- Introspection Queries: Anyone can query
__schemato download the entire API structure and type schema. (Mitigation: disable schema introspection in production).- Batching Attacks: Attackers bundle thousands of nested requests into a single HTTP post request to bypass standard HTTP rate limits. (Mitigation: limit batch sizes).
Q6: How do you implement multi-tier rate limiting?
Implement rate limits at different layers of the infrastructure:
- Layer 1: Edge CDN (Cloudflare): Block brute-force IP addresses or malicious bots before they touch your cloud environment.
- Layer 2: API Gateway (Spring Cloud Gateway / Kong): Enforce token bucket algorithms based on API Keys or Client IDs (e.g., max 100 requests/minute per client).
- Layer 3: Application Container (Bucket4j / Redis): Implement granular, user-level rate limiting (e.g., limiting search actions to 10 queries/minute) to protect database resources.
Q7: How do you prevent over-fetching of sensitive data in API responses?
- Jackson JSON Views: Use
@JsonViewannotations to select which fields are serialized to JSON depending on the controller context.- DTO Projection: Query only selected database fields using Spring Data projections, keeping unneeded entity columns out of memory.
- API Contracts: Establish rigorous schema contracts and write unit/integration tests verifying that sensitive internal identifiers are not present in serialized JSON payloads.
Q8: What is request signing and when is it needed?
Request Signing is a protocol where the client generates a cryptographic signature of the HTTP request payload and headers using a private secret key, and attaches it as a header (e.g.
X-Signature). When needed: For high-security machine-to-machine integrations (e.g., financial APIs, webhook notifications, payment processors like Stripe). It prevents tampering (requests cannot be altered in transit without invalidating the signature) and guarantees authenticity (only the owner of the private secret key could have signed it).
Q9: What is the difference between BOLA (API1) and BFLA (API5)?
- BOLA (Broken Object Level Authorization): Focuses on data records. The user has access to the endpoint, but accesses a record ID they don't own (e.g. standard User A accesses
/api/orders/999which belongs to User B).- BFLA (Broken Function Level Authorization): Focuses on roles/actions. A lower-privileged user executes an administrative API function they should not have access to (e.g. standard User A calls
/api/admin/users/123/suspend).
Q10: How does nonce-based replay protection work in request signing?
To prevent an attacker from intercepting a signed request and replaying it later:
- The client adds a unique random value (
nonce) and a timestamp (timestamp) to the signature calculation and headers.- The server receives the request and validates the timestamp is within tolerance (e.g. last 5 minutes).
- The server checks a fast cache (Redis) to see if the
noncewas already used in that time window. If yes, the request is rejected as a replay attack. If no, thenonceis cached until the timestamp window expires.
