Secure by Default: APIs That Are Hard to Misuse

Security by default is a design principle, not a feature list. An API is secure by default when using it correctly produces secure behavior automatically, and producing insecure behavior requires deliberate effort.

Most security failures are not the result of sophisticated attacks on well-designed systems. They’re the result of developers using APIs incorrectly, making incorrect assumptions about what defaults are safe, or forgetting to add security checks that should have been built in.

The Authorization Default Problem

The most common API security failure: authorization checks are the developer’s responsibility, applied inconsistently.

// Insecure by default: caller must remember to authorize
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable String id) {
    return orderService.findOrder(id)
        .map(orderMapper::toResponse)
        .orElseThrow(OrderNotFoundException::new);
    // MISSING: Who is allowed to view this order?
}

The caller might remember to add an authorization check. Or they might not. This is the wrong default.

Secure by default approach: make the unauthorized path the default, require explicit declaration of what’s accessible.

// Secure by default: requires explicit permission declaration
@GetMapping("/orders/{id}")
@PreAuthorize("hasRole('ORDER_MANAGER') or @orderSecurity.isOwner(authentication, #id)")
public OrderResponse getOrder(@PathVariable String id) {
    return orderService.findOrder(id)
        .map(orderMapper::toResponse)
        .orElseThrow(OrderNotFoundException::new);
}

Better: make resource-level authorization happen in the service layer, so it’s impossible to bypass through the controller:

public Optional<OrderSummary> findOrder(OrderId orderId, SecurityContext context) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    
    if (!context.canView(order)) {  // Authorization is part of the operation
        throw new AccessDeniedException("Cannot access order " + orderId);
    }
    
    return Optional.of(orderMapper.toSummary(order));
}

When authorization is built into the domain service, skipping it requires explicitly removing it — not merely forgetting to add it.

Input Validation: Reject Early and Clearly

Every input that crosses a trust boundary — from users, from external systems, from message queues — must be validated before use.

// Validation at the boundary, not buried in business logic
public record PlaceOrderRequest(
    @NotBlank String customerId,
    @NotEmpty @Size(max = 100) List<@Valid OrderLineRequest> items,
    @Valid @NotNull ShippingAddressRequest shippingAddress
) {}

public record OrderLineRequest(
    @NotBlank @Pattern(regexp = "PROD-[A-Z0-9]+") String productId,
    @Min(1) @Max(1000) int quantity
) {}
@PostMapping("/orders")
public ResponseEntity<OrderResponse> placeOrder(
        @Valid @RequestBody PlaceOrderRequest request,
        BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return ResponseEntity.badRequest()
            .body(ErrorResponse.validationErrors(bindingResult));
    }
    // At this point, request is guaranteed to be structurally valid
    return ResponseEntity.ok(orderService.placeOrder(request));
}

Validation principles:

  • Validate at the entry point: don’t pass unvalidated data to business logic and hope it’ll be caught somewhere
  • Be specific about what’s valid: “productId must match PROD-[A-Z0-9]+” is better than “productId must be a string”
  • Reject and return a clear error: don’t swallow invalid input and silently proceed with wrong data
  • Validate business rules separately from structural validation: a structurally valid request (non-null, correct format) may still violate business rules (product doesn’t exist, quantity exceeds stock)

Parameterized Queries: Non-Negotiable

SQL injection is decades old and still occurring because developers concatenate user input into SQL strings.

// INJECTION VULNERABILITY — never do this
String sql = "SELECT * FROM users WHERE email = '" + email + "'";

// Safe — parameterized query
String sql = "SELECT * FROM users WHERE email = ?";
// Or with Spring Data JPA:
Optional<User> findByEmail(String email); // JPA parameterizes automatically

With Spring Data, injection prevention is automatic when using repository methods and JPQL/method name queries. Danger appears when native SQL is used:

// STILL VULNERABLE even in Spring Data
@Query(value = "SELECT * FROM orders WHERE status = '" + status + "'", nativeQuery = true)
// (This is hypothetical — Java annotations don't allow this syntax,
//  but the pattern appears with string building in @Query)

// Safe native query
@Query(value = "SELECT * FROM orders WHERE status = :status", nativeQuery = true)
List<Order> findByStatus(@Param("status") String status);

With JDBC Template:

// Parameterized — safe
String sql = "SELECT * FROM orders WHERE customer_id = ?";
jdbcTemplate.query(sql, new Object[]{customerId}, orderRowMapper);

Rate Limiting: Protect Against Abuse

APIs without rate limiting can be abused: brute-force authentication, enumeration attacks, excessive API calls by misbehaving clients.

Spring Boot with bucket4j:

@Component
public class RateLimitingFilter extends OncePerRequestFilter {
    
    private final LoadingCache<String, Bucket> buckets = CacheBuilder.newBuilder()
        .expireAfterAccess(1, TimeUnit.HOURS)
        .build(CacheLoader.from(this::newBucket));
    
    private Bucket newBucket(String key) {
        return Bucket.builder()
            .addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
            .build();
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        String clientId = resolveClientId(request); // API key or IP
        Bucket bucket = buckets.getUnchecked(clientId);
        
        if (bucket.tryConsume(1)) {
            chain.doFilter(request, response);
        } else {
            response.setStatus(429);
            response.setHeader("Retry-After", "60");
            response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        }
    }
}

Apply different rate limits for different operations:

  • Authentication endpoints: tight limits (5–10 attempts per minute per IP)
  • Read operations: generous limits
  • Write operations: moderate limits
  • Expensive operations (bulk exports, PDF generation): very tight limits

Mass Assignment: Don’t Trust Request Bodies

Mass assignment vulnerabilities occur when user-supplied fields are blindly applied to domain objects.

// VULNERABLE: user can supply any field, including admin flags
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
    user.setId(id);
    return userRepository.save(user); // User might have set isAdmin=true
}

Use explicit DTOs that only contain the fields users are allowed to modify:

public record UpdateUserRequest(
    @NotBlank String displayName,
    String bio,
    @Email String contactEmail
    // NO: isAdmin, role, accountStatus — users can't update these
) {}

@PutMapping("/users/{id}")
public UserResponse updateUser(@PathVariable Long id,
                               @Valid @RequestBody UpdateUserRequest request) {
    User user = userService.findById(id);
    // Explicitly apply only the allowed fields
    user.setDisplayName(request.displayName());
    user.setBio(request.bio());
    if (request.contactEmail() != null) {
        user.setContactEmail(request.contactEmail());
    }
    return userMapper.toResponse(userRepository.save(user));
}

Error Handling: Information Disclosure

Exceptions and stack traces returned to clients reveal implementation details:

{
  "error": "org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint \"users_email_key\" Detail: Key (email)=(user@example.com) already exists."
}

This reveals: you’re using PostgreSQL, your table is named users, the column is email, and a user with that email exists.

Map exceptions to appropriate HTTP responses with minimal information:

@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException e) {
        log.warn("Data integrity violation: {}", e.getMessage()); // Log the full detail internally
        return ResponseEntity.status(409)
            .body(new ErrorResponse("CONFLICT", "A resource with the specified data already exists"));
        // External response contains no database details
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        String errorId = UUID.randomUUID().toString();
        log.error("Unexpected error {}: {}", errorId, e.getMessage(), e);
        return ResponseEntity.status(500)
            .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred. Reference: " + errorId));
        // errorId allows correlation with logs without revealing the exception
    }
}

The error ID allows support to correlate the user’s error report with the internal log entry. The external response reveals nothing about the internal implementation.

Secure Defaults in Configuration

Library and framework defaults are not always secure. Know what you’re inheriting:

Spring Security defaults (Spring Boot 3.x): CSRF protection enabled, session fixation protection, XSS headers, content-type sniffing prevention — these are good defaults.

What to explicitly configure:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .anyRequest().authenticated()  // Default: authenticated
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(STATELESS)  // For APIs
            )
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
                .frameOptions(frame -> frame.deny())
                .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000))
            )
            .build();
    }
}

The secure-by-default principle applied to your own API design: every operation should require explicit permission, every input should be validated, every error should be handled explicitly, every sensitive operation should be rate-limited. These shouldn’t be afterthoughts added to a working system — they should be structural properties of the system from the start.

securityapi-designinput-validationauthorizationsoftware-design
← All articles