Securing a Spring Boot API Beyond Authentication — OWASP Top 10 in Practice

by Eric Hanson, Backend Developer at Clean Systems Consulting

API1: Broken Object-Level Authorization (BOLA/IDOR)

The most common API vulnerability. An authenticated user accesses another user's resource by supplying a different resource ID.

// VULNERABLE
@GetMapping("/invoices/{id}")
public Invoice getInvoice(@PathVariable Long id) {
    return invoiceRepository.findById(id).orElseThrow();
    // No ownership check — user A can read user B's invoice
}

// SAFE — query enforces ownership
@GetMapping("/invoices/{id}")
public Invoice getInvoice(@PathVariable Long id,
        @AuthenticationPrincipal AppUserDetails user) {
    return invoiceRepository.findByIdAndOwnerId(id, user.getId())
        .orElseThrow(() -> new InvoiceNotFoundException(id));
}

Mitigation: For every endpoint accepting a resource ID, verify the caller owns or has explicit permission to access that resource. Use repository queries that include ownership in the WHERE clause rather than loading then checking. Return 404 rather than 403 for unauthorized access — don't confirm that the resource exists.

API2: Broken Authentication

Weak JWT implementations, missing token expiry validation, predictable tokens, no token revocation.

// VULNERABLE — doesn't validate expiry or signature properly
public boolean validateToken(String token) {
    try {
        Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
        return true; // only checks signature, not expiry
    } catch (Exception e) {
        return false;
    }
}

// SAFE — Spring Security OAuth2 resource server handles this correctly
@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri(jwksUri).build();

    // Validates: signature, expiry, not-before, issuer, audience
    OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
        JwtValidators.createDefaultWithIssuer(issuerUri),
        new JwtClaimValidator<>(JwtClaimNames.AUD,
            aud -> ((List<?>) aud).contains("my-service"))
    );
    decoder.setJwtValidator(validators);
    return decoder;
}

Mitigation: Use Spring Security's OAuth2 resource server — it handles token validation correctly. Validate: signature, expiry (exp), not-before (nbf), issuer (iss), and audience (aud). Short-lived access tokens (15 minutes) with refresh tokens reduce the blast radius of token theft.

API3: Broken Object Property Level Authorization

An authenticated user can read or write fields they shouldn't have access to on resources they legitimately access.

// VULNERABLE — admin-only fields visible to regular users
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
    // Returns: id, email, name, AND internalScore, fraudFlag, adminNotes
}

// VULNERABLE — user can set their own role via mass assignment
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
    user.setId(id);
    return userRepository.save(user); // saves whatever role the client sends
}

Mitigation: Use role-specific response objects that include only the fields each role should see:

@GetMapping("/users/{id}")
public Object getUser(@PathVariable Long id,
        @AuthenticationPrincipal AppUserDetails currentUser) {
    User user = userRepository.findById(id).orElseThrow();

    if (currentUser.hasRole("ADMIN")) {
        return AdminUserResponse.from(user); // includes internal fields
    }
    return PublicUserResponse.from(user);    // public fields only
}

For writes, use request objects that only expose the fields a user may set — never bind request body directly to an entity.

API4: Unrestricted Resource Consumption

No rate limiting, no request size limits, expensive operations callable without throttling.

// VULNERABLE — no rate limiting, no size limit, expensive operation
@PostMapping("/search")
public List<SearchResult> search(@RequestBody SearchRequest request) {
    return searchService.fullTextSearch(request.getQuery()); // unbounded
}

Mitigation: Apply rate limiting per user/IP, limit request body size, cap result set sizes, and add timeouts:

@PostMapping("/search")
@RateLimiter(name = "search-endpoint")  // Resilience4j — 10 requests/minute per user
public Page<SearchResult> search(
        @RequestBody @Valid SearchRequest request,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") @Max(100) int size) {

    return searchService.search(request.getQuery(),
        PageRequest.of(page, size));  // paginated, size-capped
}

Configure global request size limits:

spring:
  servlet:
    multipart:
      max-request-size: 10MB
server:
  tomcat:
    max-swallow-size: 2MB

API5: Broken Function Level Authorization

Endpoints for privileged functions (admin operations, internal tools) accessible to regular users because the check is URL-based and the attacker guesses the path.

// VULNERABLE — admin endpoint with only URL-level protection
// If a user guesses /api/admin/users, they can access it
@GetMapping("/api/admin/users")
public List<User> getAllUsers() {
    return userRepository.findAll();
}

Mitigation: Enforce function-level authorization at the method level, not just the URL:

@GetMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")  // enforced regardless of URL knowledge
public List<User> getAllUsers() {
    return userRepository.findAll();
}

Additionally, separate admin APIs onto a different port or host (not accessible through the public load balancer):

management:
  server:
    port: 8081  # admin endpoints on separate port, internal network only

API6: Unrestricted Access to Sensitive Business Flows

Business logic flows that can be exploited — bulk purchase to exhaust inventory, mass account creation for spam, automated voting.

// VULNERABLE — no protection against automated exploitation
@PostMapping("/products/{id}/purchase")
public Purchase buyProduct(@PathVariable Long id,
        @RequestBody PurchaseRequest request) {
    return purchaseService.purchase(id, request.getQuantity());
    // Bot can purchase all inventory in milliseconds
}

Mitigation: Combine rate limiting with business logic validation:

@PostMapping("/products/{id}/purchase")
@RateLimiter(name = "purchase")  // 10 purchases per hour per user
public Purchase buyProduct(@PathVariable Long id,
        @RequestBody @Valid PurchaseRequest request,
        @AuthenticationPrincipal AppUserDetails user) {

    // Business rule: max 5 of any single product per day per user
    long todayPurchases = purchaseRepository
        .countByUserIdAndProductIdAndCreatedAtAfter(
            user.getId(), id, Instant.now().minus(Duration.ofDays(1)));

    if (todayPurchases + request.getQuantity() > 5) {
        throw new PurchaseLimitExceededException("Maximum 5 per day");
    }

    return purchaseService.purchase(id, request.getQuantity(), user.getId());
}

API7: Server-Side Request Forgery (SSRF)

The application fetches a URL provided by the user — an attacker provides an internal URL to probe internal services or cloud metadata endpoints.

// VULNERABLE — fetches user-controlled URL
@PostMapping("/webhooks/test")
public WebhookTestResult testWebhook(@RequestBody TestWebhookRequest request) {
    // Attacker sends: url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
    return webClient.post()
        .uri(request.getUrl())  // user-controlled URL — SSRF
        .retrieve()
        .bodyToMono(WebhookTestResult.class)
        .block();
}

Mitigation: Validate URLs against an allowlist before fetching:

@Component
public class UrlValidator {

    private static final List<String> BLOCKED_PATTERNS = List.of(
        "169.254.", "10.", "172.16.", "192.168.", "127.", "localhost", "0.0.0.0",
        "metadata.google.internal", "::1"
    );

    public void validateWebhookUrl(String url) {
        try {
            URI uri = new URI(url);
            if (!List.of("https", "http").contains(uri.getScheme())) {
                throw new InvalidUrlException("Only http/https URLs allowed");
            }
            String host = uri.getHost().toLowerCase();
            if (BLOCKED_PATTERNS.stream().anyMatch(host::contains)) {
                throw new InvalidUrlException("URL points to restricted address");
            }
            // Also resolve DNS and check the resolved IP
            InetAddress address = InetAddress.getByName(host);
            if (address.isLoopbackAddress() || address.isSiteLocalAddress() ||
                    address.isLinkLocalAddress()) {
                throw new InvalidUrlException("URL resolves to restricted address");
            }
        } catch (URISyntaxException | UnknownHostException e) {
            throw new InvalidUrlException("Invalid URL: " + e.getMessage());
        }
    }
}

DNS rebinding requires re-resolving the hostname at request time — validate, then use the validated IP directly in the request.

API8: Security Misconfiguration

Verbose error messages, stack traces in responses, unnecessary endpoints exposed, default credentials, debug mode in production.

# VULNERABLE — exposes too much
server:
  error:
    include-stacktrace: always
    include-message: always

management:
  endpoints:
    web:
      exposure:
        include: "*"  # exposes heapdump, env, shutdown

spring:
  datasource:
    url: jdbc:postgresql://localhost/db
    username: postgres
    password: postgres  # default credentials

Mitigation:

server:
  error:
    include-stacktrace: never
    include-message: never   # use custom error messages only

management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
  server:
    port: 8081  # internal only

spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}

Custom error handler that never exposes internals:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex,
            HttpServletRequest request) {
        log.error("Unhandled exception for {}", request.getRequestURI(), ex);
        // Log the real exception, return a safe message to the client
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("internal_error",
                "An unexpected error occurred. Contact support if this persists."));
    }
}

API9: Improper Inventory Management

Undocumented or forgotten API versions still accessible, debug or test endpoints left in production, different security posture between versions.

Mitigation: Track every exposed endpoint:

# Generate a list of all mapped endpoints at startup
curl http://localhost:8080/actuator/mappings | jq '.contexts.application.mappings.dispatcherServlets'

Keep an endpoint inventory and compare against expected endpoints in CI. Any unexpected endpoint in the inventory is a misconfiguration. Disable Swagger UI and API docs in production unless explicitly required:

springdoc:
  swagger-ui:
    enabled: ${SWAGGER_ENABLED:false}
  api-docs:
    enabled: ${API_DOCS_ENABLED:false}

API10: Unsafe Consumption of APIs

Trusting data from third-party APIs without validation — SSRF via a compromised third party, injection via unvalidated data from external APIs.

// VULNERABLE — blindly trusts data from external API
@GetMapping("/products/{id}/reviews")
public List<Review> getReviews(@PathVariable Long id) {
    // If the reviews API is compromised, it could inject malicious data
    return reviewApiClient.getReviews(id);
    // Stored as-is, including potential XSS payloads or SQL injection content
}

Mitigation: Validate and sanitize data from all external APIs before storing or returning:

@GetMapping("/products/{id}/reviews")
public List<ReviewResponse> getReviews(@PathVariable Long id) {
    List<ExternalReview> rawReviews = reviewApiClient.getReviews(id);

    return rawReviews.stream()
        .filter(r -> r.getRating() >= 1 && r.getRating() <= 5)  // validate range
        .filter(r -> r.getContent() != null && r.getContent().length() <= 5000)  // size limit
        .map(r -> new ReviewResponse(
            r.getRating(),
            sanitizeHtml(r.getContent()),  // remove XSS payloads
            truncate(r.getAuthor(), 100)   // enforce length limits
        ))
        .toList();
}

Use a library like OWASP Java HTML Sanitizer for HTML content from external sources. Never store unsanitized external content that will be returned to clients.

The security review checklist

For each Spring Boot API endpoint, verify:

  1. BOLA: Does accepting this ID require an ownership check? Is the check implemented?
  2. Authentication: Is token validation delegating to Spring Security's resource server? Are all required claims validated?
  3. Property-level: Does the response contain fields the caller shouldn't see? Does the request bind to fields the caller shouldn't set?
  4. Rate limiting: Is there a rate limit on this endpoint? Is it appropriate for the endpoint's cost?
  5. Function-level: Does @PreAuthorize enforce the required role at the method level?
  6. Business flows: Can this endpoint be exploited by automation? Are there business-logic guards?
  7. SSRF: Does this endpoint fetch a user-provided URL? Is it validated against an allowlist?
  8. Error handling: Does the error response expose internal details? Are all exceptions caught and sanitized?
  9. Inventory: Is this endpoint documented and expected? Is it in the right API version?
  10. External data: Is data from external APIs validated and sanitized before use?

Authentication is necessary. The ten categories above are where authenticated APIs fail in production.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Not Using a Git Server Is a Recipe for Lost Code

Trusting your local folder to hold the only copy of your code? It sounds fine until the inevitable crash—or accidental delete—happens.

Read more

Toronto Has More Backend Developers Than Most Cities — and Still Cannot Fill Senior Roles Fast Enough

Toronto's developer population is genuinely large. The senior backend engineers your startup needs are still harder to hire than they should be.

Read more

Async Is Not a Compromise — It Is How the Best Remote Backend Teams Actually Work

Async remote work has a reputation as the fallback option when synchronous isn't possible. That reputation is wrong, and the teams doing backend development best know it.

Read more

Why Belgrade Startups Need to Think Beyond Local Hiring to Scale Their Backend Teams

Belgrade's backend engineering community is strong and growing. It's not growing fast enough to keep up with the demand local startups are creating.

Read more