Broken Object-Level Authorization in Spring Boot — How to Detect and Prevent IDOR

by Arif Ikhsanudin, Backend Developer

What IDOR looks like and why it's so common

An API endpoint that accepts a resource ID and returns the resource without checking ownership is vulnerable:

// VULNERABLE — any authenticated user can access any order
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
    return OrderResponse.from(orderRepository.findById(id).orElseThrow());
}

An attacker authenticated as user A calls /orders/12345 where order 12345 belongs to user B. The endpoint returns user B's order details — credit card last four, shipping address, purchase history — because the only check is "is this user authenticated," not "does this user own order 12345."

IDOR is common because authentication and object-level authorization are solved at different places in the code. Authentication is handled by Spring Security at the filter level — once implemented, it works for all endpoints. Object-level authorization must be implemented for every endpoint that accepts a resource identifier, and there's no framework mechanism that does it automatically. It's easy to implement authentication and assume authorization is covered.

The fix — owner verification at every resource access

Every endpoint that retrieves or modifies a specific resource must verify the caller has the right to access that resource:

// SAFE — verifies ownership before returning
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id,
        @AuthenticationPrincipal AppUserDetails currentUser) {

    Order order = orderRepository.findById(id).orElseThrow();

    if (!order.getUserId().equals(currentUser.getId()) &&
            !currentUser.hasRole("ADMIN")) {
        throw new AccessDeniedException("Not authorized to view order " + id);
    }

    return OrderResponse.from(order);
}

Or move the authorization into the repository query — only return the order if it belongs to the current user:

// Authorization enforced in the query — cleaner
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id,
        @AuthenticationPrincipal AppUserDetails currentUser) {

    Order order = orderRepository.findByIdAndUserId(id, currentUser.getId())
        .orElseThrow(() -> new OrderNotFoundException(id));
    // Returns 404 instead of 403 — doesn't reveal that the order exists

    return OrderResponse.from(order);
}

The second approach has a security advantage: returning 404 for unauthorized access doesn't reveal whether the resource exists. An attacker enumerating IDs can't distinguish "this order doesn't exist" from "this order exists but you can't see it." Returning 403 for existing resources reveals that the resource exists — useful information for an attacker.

@PreAuthorize — declarative ownership checks

For consistent enforcement, move the ownership check to @PreAuthorize with a bean method:

@Service("orderSecurity")
public class OrderSecurityService {

    private final OrderRepository orderRepository;

    public boolean isOwner(Long orderId, Authentication auth) {
        if (auth == null) return false;
        AppUserDetails user = (AppUserDetails) auth.getPrincipal();
        if (user.hasRole("ADMIN")) return true;

        return orderRepository.existsByIdAndUserId(orderId, user.getId());
    }

    public boolean canModify(Long orderId, Authentication auth) {
        if (auth == null) return false;
        AppUserDetails user = (AppUserDetails) auth.getPrincipal();
        if (user.hasRole("ADMIN")) return true;

        // Only owner can modify — team members can read but not modify
        return orderRepository.existsByIdAndUserId(orderId, user.getId());
    }
}
@GetMapping("/{id}")
@PreAuthorize("@orderSecurity.isOwner(#id, authentication)")
public OrderResponse getOrder(@PathVariable Long id) {
    return OrderResponse.from(orderRepository.findById(id).orElseThrow());
}

@DeleteMapping("/{id}")
@PreAuthorize("@orderSecurity.canModify(#id, authentication)")
public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
    orderService.deleteOrder(id);
    return ResponseEntity.noContent().build();
}

The security method runs a query (existsByIdAndUserId) — this is a database call per request. For hot paths, cache the result:

public boolean isOwner(Long orderId, Authentication auth) {
    AppUserDetails user = (AppUserDetails) auth.getPrincipal();
    if (user.hasRole("ADMIN")) return true;

    // Cache ownership check for 30 seconds — acceptable staleness for authorization
    String cacheKey = "order-ownership:" + orderId + ":" + user.getId();
    return ownershipCache.get(cacheKey,
        () -> orderRepository.existsByIdAndUserId(orderId, user.getId()));
}

IDOR through related resources

IDOR doesn't only occur on direct resource access. Accessing a resource through its relationships is equally vulnerable:

// VULNERABLE — user can access any order's line items if they know the order ID
@GetMapping("/orders/{orderId}/line-items")
public List<LineItemResponse> getLineItems(@PathVariable Long orderId) {
    return lineItemRepository.findByOrderId(orderId)
        .stream().map(LineItemResponse::from).toList();
}

User A can access the line items of user B's order by calling /orders/{userB_orderId}/line-items. The ownership check must cascade through the relationship:

@GetMapping("/orders/{orderId}/line-items")
@PreAuthorize("@orderSecurity.isOwner(#orderId, authentication)")
public List<LineItemResponse> getLineItems(@PathVariable Long orderId) {
    return lineItemRepository.findByOrderId(orderId)
        .stream().map(LineItemResponse::from).toList();
}

Or join the ownership check into the query:

@Query("SELECT li FROM LineItem li " +
       "WHERE li.order.id = :orderId AND li.order.userId = :userId")
List<LineItem> findByOrderIdAndUserId(
    @Param("orderId") Long orderId,
    @Param("userId") Long userId);

The IDOR surface area of nested resources is every endpoint that accepts a parent resource ID. Each nested endpoint is a potential access vector to the parent resource's data.

Mass IDOR — bulk operations

Bulk endpoints that accept arrays of IDs are particularly risky:

// VULNERABLE — attacker includes IDs belonging to other users
@PostMapping("/orders/batch-cancel")
public BatchResult batchCancel(@RequestBody BatchCancelRequest request,
        @AuthenticationPrincipal AppUserDetails currentUser) {

    request.orderIds().forEach(orderId ->
        orderService.cancelOrder(orderId));  // no ownership check per ID

    return BatchResult.success(request.orderIds().size());
}

The fix: filter to only the caller's resources before processing:

@PostMapping("/orders/batch-cancel")
public BatchResult batchCancel(@RequestBody BatchCancelRequest request,
        @AuthenticationPrincipal AppUserDetails currentUser) {

    // Only cancel orders that belong to the current user
    List<Long> ownedOrderIds = orderRepository
        .findByIdInAndUserId(request.orderIds(), currentUser.getId())
        .stream().map(Order::getId).toList();

    ownedOrderIds.forEach(orderId -> orderService.cancelOrder(orderId));

    // Return count of successfully cancelled — don't reveal which IDs were filtered
    return BatchResult.success(ownedOrderIds.size());
}

Don't return which IDs were filtered out (not owned) — this reveals that those resources exist and belong to another user.

Using opaque IDs to reduce enumeration risk

Sequential integer IDs (/orders/1, /orders/2, /orders/3) make enumeration trivial — an attacker increments the ID and tries every value. UUIDs are harder to enumerate but not impossible (still worth trying random UUIDs). For resources where enumeration is a significant concern, use opaque random tokens:

@Entity
public class Order {
    @Id
    @GeneratedValue
    private Long id;          // internal, never exposed via API

    @Column(unique = true, nullable = false)
    private String publicId = UUID.randomUUID().toString().replace("-", ""); // API-facing ID
}

// Repository
Optional<Order> findByPublicId(String publicId);

// Controller
@GetMapping("/{publicId}")
public OrderResponse getOrder(@PathVariable String publicId,
        @AuthenticationPrincipal AppUserDetails user) {
    Order order = orderRepository.findByPublicId(publicId)
        .filter(o -> o.getUserId().equals(user.getId()))
        .orElseThrow(() -> new OrderNotFoundException(publicId));
    return OrderResponse.from(order);
}

The public ID is a random 32-character string — guessing it is computationally infeasible. Sequential integer IDs in the database are never exposed. This doesn't replace object-level authorization — a guessed UUID would still require ownership verification — but it eliminates the practical enumeration attack.

Testing for IDOR

IDOR bugs are hard to catch in code review because the vulnerability is not in any single method but in the interaction between authentication (which works) and authorization (which is missing). The most reliable detection is automated security tests:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderIdorTest {

    @Autowired TestRestTemplate restTemplate;
    @Autowired UserRepository userRepository;
    @Autowired OrderRepository orderRepository;

    @Test
    void userCannotAccessAnotherUsersOrder() {
        // Create two users and their orders
        String aliceToken = createUserAndGetToken("alice@example.com");
        String bobToken   = createUserAndGetToken("bob@example.com");

        Order aliceOrder = createOrder(aliceToken);

        // Bob tries to access Alice's order
        ResponseEntity<OrderResponse> response = restTemplate.exchange(
            "/api/v1/orders/" + aliceOrder.getId(),
            HttpMethod.GET,
            new HttpEntity<>(headersWithToken(bobToken)),
            OrderResponse.class
        );

        // Should be 403 or 404, never 200
        assertThat(response.getStatusCode())
            .isIn(HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND);

        // If 200, verify it's not returning Alice's data
        if (response.getStatusCode() == HttpStatus.OK) {
            fail("Bob accessed Alice's order: " + aliceOrder.getId());
        }
    }

    @Test
    void userCannotModifyAnotherUsersOrder() {
        String aliceToken = createUserAndGetToken("alice@example.com");
        String bobToken   = createUserAndGetToken("bob@example.com");

        Order aliceOrder = createOrder(aliceToken);

        ResponseEntity<Void> response = restTemplate.exchange(
            "/api/v1/orders/" + aliceOrder.getId(),
            HttpMethod.DELETE,
            new HttpEntity<>(headersWithToken(bobToken)),
            Void.class
        );

        assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.OK);
        assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.NO_CONTENT);

        // Verify the order still exists
        assertThat(orderRepository.existsById(aliceOrder.getId())).isTrue();
    }
}

Add one IDOR test per resource type. Run them in CI. When a new resource is added, adding the IDOR test forces the developer to think through the authorization boundary.

The systematic approach — every endpoint is a potential IDOR

An IDOR audit for a Spring Boot API involves checking every controller method that accepts a resource identifier (@PathVariable, @RequestParam with an ID field, @RequestBody with nested IDs):

  1. Does the endpoint retrieve a specific resource by ID? → Verify ownership before returning
  2. Does the endpoint modify a resource by ID? → Verify ownership before modifying
  3. Does the endpoint accept an array of IDs? → Filter to owned IDs before processing
  4. Does the endpoint access related resources via a parent ID? → Verify ownership of the parent

For each case, the question is: if an authenticated user provides an ID belonging to another user, what happens? If the answer is "they get the data" or "the modification succeeds," the endpoint is vulnerable.

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

How to Stay Visible to Clients Even When You Are Not Working With Them

Being top of mind with past and potential clients does not require constant selling. It requires occasional, genuine presence in their professional orbit.

Read more

What Happens When You Accidentally Delete the Production Database

One wrong click in production. Data disappears. Panic sets in. But who’s really at fault? Spoiler: it’s not the developer.

Read more

Ruby vs Java for Backend — A Honest Comparison from Someone Who Uses Both

After shipping production systems in both Ruby and Java for over a decade, the honest answer is that each language has a context where it dominates — and using the wrong one costs more than people admit.

Read more

New York Startups Are Rethinking Full-Time Backend Hires — Here Is Why

You posted the job listing six weeks ago. You're still interviewing — and your backend hasn't moved an inch.

Read more