Skip to main content

Command Palette

Search for a command to run...

Broken Access Control in Java and Spring: Secure Implementation Patterns (Part 1)

Updated
21 min read
Broken Access Control in Java and Spring: Secure Implementation Patterns (Part 1)
M

20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.

Broken Access Control sits at the top of the OWASP Top 10 2025, and that ranking tells an important story. Authentication answers a simple question: who is this person? Authorization answers a harder one: what are they allowed to do? Most applications get authentication right because the frameworks hand you working patterns and failures show up immediately. Authorization works differently. The logic depends entirely on your business domain, failures happen quietly, and the attack surface expands with every feature you ship.

Insecure Direct Object Reference (IDOR) is the most frequent type of insecure access control. The idea behind it is easy to grasp. You provide access to some object of your application, such as a database identifier or a file name, without verifying whether the one who requested it had a right to view it. So, User A goes to /api/orders/12345, and his/her order is fetched. Now, User A attempts to view api/orders/12346 and sees User B's order. Nothing complex here, not even any sort of injection.

IDOR maps to CWE-639 (Authorization Bypass Through User-Controlled Key) and accounts for a big chunk of what OWASP categorizes as A01:2025 Broken Access Control. These vulnerabilities keep showing up in production even though we have known about them for decades. The issue is not that developers lack awareness. The issue is that authorization logic needs to be correct in every single endpoint, every controller method, every service call that touches user-specific data. Miss one spot and you have a vulnerability.

1. Authentication Versus Authorization

Before we get into the vulnerabilities, it helps to nail down terminology that people sometimes mix up.

Authentication is about identity. The user presents some credentials, and the system figures out who they are. Spring Security handles this through authentication filters, authentication providers, and the SecurityContext. After authentication succeeds, the application knows which principal is making requests.

Authorization is about permission. Given a user whose identity is established, the system decides whether they can perform a particular action on a particular resource. Spring Security gives you several ways to handle this: method-level annotations, URL-based rules, and the AccessDecisionManager hierarchy.

This series focuses specifically on authorization failures where users who are properly authenticated still manage to access resources or perform actions they should not be allowed to touch.

Authorization failures come in two flavors.

Horizontal Privilege Escalation occurs when a user accesses other users' resources that belong to the user with similar privileges. User A manages to access the orders, profiles, and documents of User B. Both have similar roles and capabilities. The application fails to authenticate the ownership of the resources.

Vertical Privilege Escalation occurs when the user gains access to resources and functionalities designed for high-privilege users. An ordinary user gains access to administrative functions and is able to alter system configurations.

This blog post deals with horizontal privilege escalation and IDOR. The next part will discusses vertical privilege escalation.

2. Direct Object Reference: Where the Problem Starts

Each entry in the database should have an ID. Each API endpoint will need some means of recognizing which entry to access, modify, or delete. From the second we start using the ID in our query, body, or header, we're creating an access control vulnerability.

The Vulnerable Pattern

Here is a basic order retrieval endpoint:

package com.example.orders.controller;

import com.example.orders.model.Order;
import com.example.orders.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    // VULNERABLE: No authorization check
    @GetMapping("/{orderId}")
    public ResponseEntity<Order> getOrder(@PathVariable Long orderId) {
        return orderService.findById(orderId)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

This endpoint does authenticate users, assuming you have Spring Security configured. Nevertheless, there is no validation done here to make sure that this authenticated user owns this order being requested. Any logged-in user can pull up any order just by cycling through IDs.

The service layer makes things worse:

package com.example.orders.service;

import com.example.orders.model.Order;
import com.example.orders.repository.OrderRepository;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // VULNERABLE: Fetches any order without ownership verification
    public Optional<Order> findById(Long orderId) {
        return orderRepository.findById(orderId);
    }
}

The vulnerability boils down to trust. The code trusts whatever ID the client sends without checking ownership. Authentication told us who made the request. Authorization should tell us whether they have any business accessing this particular resource.

Why This Keeps Happening

A few things contribute to IDOR vulnerabilities sticking around.

Frameworks default to functionality, not security. Spring Data JPA gives us findById() right out of the box. That method does exactly what it says: finds records by ID. If we want ownership checks, we have to build them ourself.

Authorization depends entirely on the application. Authentication can happen in many ways including username/password, OAuth, and SAML. However, authorization is domain-specific. The framework will not know anything about how orders are related to users, documents are related to projects, and messages are related to conversations. We have to make those connections ourself.

Tests generally do not cover authorization. Tests verify that endpoints deliver proper results. They hardly ever verify that endpoints will not return any result to unqualified users. If things work, then everything goes fine.

Incremental development introduces cracks. A person creates another endpoint. They simply copy one of the controllers. But that controller has authorization checks specific to its domain model. And the copied version does not have those authorization checks.

3. Horizontal Privilege Escalation Patterns

IDOR shows up through different request mechanisms. Knowing each vector helps us spot where authorization checks need to go.

Path Parameter References

The most common pattern puts resource IDs in the URL path:

// VULNERABLE: Path parameter without ownership check
@GetMapping("/api/users/{userId}/documents/{documentId}")
public ResponseEntity<Document> getDocument(
        @PathVariable Long userId,
        @PathVariable Long documentId) {

    return documentService.findById(documentId)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

An attacker can change both the userId and documentId in the URL. Even if the application validates that the document belongs to the specified user, it never checks whether the authenticated user matches that userId in the path.

Secure Implementation:

package com.example.documents.controller;

import com.example.documents.model.Document;
import com.example.documents.service.DocumentService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/documents")
public class DocumentController {

    private final DocumentService documentService;

    public DocumentController(DocumentService documentService) {
        this.documentService = documentService;
    }

    @GetMapping("/{documentId}")
    public ResponseEntity<Document> getDocument(
            @PathVariable Long documentId,
            @AuthenticationPrincipal UserDetails userDetails) {

        return documentService.findByIdForUser(documentId, userDetails.getUsername())
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

The service handles ownership at the query level:

package com.example.documents.service;

import com.example.documents.model.Document;
import com.example.documents.repository.DocumentRepository;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class DocumentService {

    private final DocumentRepository documentRepository;

    public DocumentService(DocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    public Optional<Document> findByIdForUser(Long documentId, String username) {
        return documentRepository.findByIdAndOwnerUsername(documentId, username);
    }
}

The repository method ties ID lookup to ownership in a single query:

package com.example.documents.repository;

import com.example.documents.model.Document;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface DocumentRepository extends JpaRepository<Document, Long> {

    Optional<Document> findByIdAndOwnerUsername(Long id, String username);
}

With this pattern, even if an attacker knows valid document IDs, they only get back documents they actually own. The query returns an empty Optional for documents belonging to other users, and the controller sends back a 404. From the attacker's view, the document either does not exist or they cannot access it. That ambiguity is intentional and helpful.

Query Parameter References

Query parameters carry the same risk:

// VULNERABLE: Query parameter without ownership check
@GetMapping("/api/invoices")
public ResponseEntity<Invoice> getInvoice(@RequestParam Long invoiceId) {
    return invoiceService.findById(invoiceId)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

An attacker switches from ?invoiceId=100 to ?invoiceId=101 and pulls up someone else's invoice.

Secure Implementation:

package com.example.invoices.controller;

import com.example.invoices.model.Invoice;
import com.example.invoices.service.InvoiceService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceService invoiceService;

    public InvoiceController(InvoiceService invoiceService) {
        this.invoiceService = invoiceService;
    }

    @GetMapping
    public ResponseEntity<Invoice> getInvoice(
            @RequestParam Long invoiceId,
            @AuthenticationPrincipal UserDetails userDetails) {

        return invoiceService.findByIdAndCustomer(invoiceId, userDetails.getUsername())
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

Request Body References

JSON request bodies can hold object references that attackers tamper with:

package com.example.messages.dto;

public record SendMessageRequest(
        Long conversationId,
        String content
) {}
// VULNERABLE: Trusts conversationId from request body
@PostMapping("/api/messages")
public ResponseEntity<Message> sendMessage(@RequestBody SendMessageRequest request) {
    Message message = messageService.createMessage(
            request.conversationId(),
            request.content()
    );
    return ResponseEntity.ok(message);
}

An attacker posts a message to a conversation they are not part of by plugging in a different conversationId.

Secure Implementation:

package com.example.messages.controller;

import com.example.messages.dto.SendMessageRequest;
import com.example.messages.exception.AccessDeniedException;
import com.example.messages.model.Message;
import com.example.messages.service.ConversationService;
import com.example.messages.service.MessageService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/messages")
public class MessageController {

    private final MessageService messageService;
    private final ConversationService conversationService;

    public MessageController(MessageService messageService,
                            ConversationService conversationService) {
        this.messageService = messageService;
        this.conversationService = conversationService;
    }

    @PostMapping
    public ResponseEntity<Message> sendMessage(
            @RequestBody SendMessageRequest request,
            @AuthenticationPrincipal UserDetails userDetails) {

        // Verify user is participant in the conversation
        if (!conversationService.isParticipant(request.conversationId(),
                                               userDetails.getUsername())) {
            throw new AccessDeniedException("Not a participant in this conversation");
        }

        Message message = messageService.createMessage(
                request.conversationId(),
                userDetails.getUsername(),
                request.content()
        );
        return ResponseEntity.ok(message);
    }
}

The ConversationService handles the authorization check:

package com.example.messages.service;

import com.example.messages.repository.ConversationRepository;
import org.springframework.stereotype.Service;

@Service
public class ConversationService {

    private final ConversationRepository conversationRepository;

    public ConversationService(ConversationRepository conversationRepository) {
        this.conversationRepository = conversationRepository;
    }

    public boolean isParticipant(Long conversationId, String username) {
        return conversationRepository.existsByIdAndParticipantsUsername(
                conversationId, username);
    }
}

Header-Based References

Sometimes applications pass object references in custom headers:

// VULNERABLE: Trusts tenant ID from header
@GetMapping("/api/data")
public ResponseEntity<List<DataRecord>> getData(
        @RequestHeader("X-Tenant-Id") Long tenantId) {

    return ResponseEntity.ok(dataService.findByTenant(tenantId));
}

An attacker changes the X-Tenant-Id header and accesses another tenant's data in a multi-tenant system.

Secure Implementation:

package com.example.multitenant.controller;

import com.example.multitenant.model.DataRecord;
import com.example.multitenant.service.DataService;
import com.example.multitenant.service.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/data")
public class DataController {

    private final DataService dataService;
    private final TenantService tenantService;

    public DataController(DataService dataService, TenantService tenantService) {
        this.dataService = dataService;
        this.tenantService = tenantService;
    }

    @GetMapping
    public ResponseEntity<List<DataRecord>> getData(
            @AuthenticationPrincipal UserDetails userDetails) {

        // Derive tenant from authenticated user, never from request
        Long tenantId = tenantService.getTenantIdForUser(userDetails.getUsername());
        return ResponseEntity.ok(dataService.findByTenant(tenantId));
    }
}

This pattern differs from the earlier examples. Instead of validating a user-supplied tenant ID, the secure version derives the tenant ID from the authenticated user. The request has no influence over which tenant's data gets returned.

4. The UUID Misconception

A common but flawed defense against IDOR replaces sequential integer IDs with UUIDs:

Before: /api/orders/12345
After:  /api/orders/7f3b8c2a-9d4e-4f5a-b6c1-2e8f9a0b3d4c

The thinking goes like this: attackers cannot guess UUIDs, so they cannot enumerate resources. This is security through obscurity, and it breaks down for several reasons.

UUIDs leak all over the place. Order confirmation emails include the order URL. Browser history stores the URL. Support tickets reference the order ID. Slack messages share order links. Once someone knows a UUID, the missing authorization check becomes exploitable.

UUIDs do not stop targeted attacks. An attacker going after a specific user can get resource IDs through social engineering, phishing, or by compromising the target's email. The UUID offers no protection when the attacker already has the identifier.

Enumeration is still possible. Version 1 UUIDs contain timestamps and MAC addresses. Version 4 UUIDs are random, but attackers with enough resources can still try enumeration against APIs without rate limits. A 128-bit UUID gives you around 2^122 possible values for version 4, but most applications have far fewer actual resources.

The real problem stays unsolved. The application still has no authorization checks. The UUID is a weak band-aid, not a fix.

UUIDs do offer legitimate benefits for other reasons. They hide internal sequence numbers, prevent leaking information about how many records exist, and make distributed ID generation simpler. Use them for those purposes, not as an access control mechanism.

// STILL VULNERABLE: UUID without authorization check
@GetMapping("/api/orders/{orderId}")
public ResponseEntity<Order> getOrder(@PathVariable UUID orderId) {
    return orderService.findById(orderId)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

The need for authorization stays exactly the same regardless of ID format.

5. Spring Security Method-Level Authorization

Spring Security gives us declarative authorization through method-level annotations. These annotations put authorization rules right in the code, making requirements explicit and enforceable.

Enabling Method Security

First, turn on method-level security in your configuration:

package com.example.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
    // This enables @PreAuthorize, @PostAuthorize, and related annotations
}

Using @PreAuthorize for Ownership Checks

The @PreAuthorize annotation evaluates a SpEL expression before the method runs:

package com.example.orders.service;

import com.example.orders.model.Order;
import com.example.orders.repository.OrderRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @PreAuthorize("@orderAuthorizationService.canAccess(#orderId, authentication.name)")
    public Optional<Order> findById(Long orderId) {
        return orderRepository.findById(orderId);
    }

    @PreAuthorize("@orderAuthorizationService.canModify(#orderId, authentication.name)")
    public Order updateOrder(Long orderId, OrderUpdateRequest request) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.setShippingAddress(request.shippingAddress());
        order.setNotes(request.notes());

        return orderRepository.save(order);
    }
}

The authorization service holds the ownership logic:

package com.example.orders.security;

import com.example.orders.model.OrderStatus;
import com.example.orders.repository.OrderRepository;
import org.springframework.stereotype.Service;

@Service("orderAuthorizationService")
public class OrderAuthorizationService {

    private final OrderRepository orderRepository;

    public OrderAuthorizationService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public boolean canAccess(Long orderId, String username) {
        return orderRepository.existsByIdAndCustomerUsername(orderId, username);
    }

    public boolean canModify(Long orderId, String username) {
        return orderRepository.existsByIdAndCustomerUsernameAndStatusNot(
                orderId, username, OrderStatus.SHIPPED);
    }
}

When the SpEL expression returns false, Spring Security throws an AccessDeniedException. The method body never executes. This prevents race conditions where authorization checks and data access are separate operations.

Using @PostAuthorize for Response Filtering

The @PostAuthorize annotation evaluates after the method returns. This is useful when authorization depends on the returned object:

package com.example.documents.service;

import com.example.documents.model.Document;
import com.example.documents.repository.DocumentRepository;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class DocumentService {

    private final DocumentRepository documentRepository;

    public DocumentService(DocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    @PostAuthorize("returnObject.isEmpty() or returnObject.get().owner.username == authentication.name")
    public Optional<Document> findById(Long documentId) {
        return documentRepository.findById(documentId);
    }
}

One thing to watch with @PostAuthorize: the method runs before authorization gets checked. If your method has side effects like logging, metrics, or database writes, those happen even when authorization fails. Stick with @PreAuthorize when you can.

Combining Role and Ownership Checks

Real authorization often mixes role-based and ownership-based rules:

package com.example.orders.service;

import com.example.orders.model.Order;
import com.example.orders.model.OrderStatus;
import com.example.orders.repository.OrderRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // Admins can access any order; users can only access their own
    @PreAuthorize("hasRole('ADMIN') or @orderAuthorizationService.canAccess(#orderId, authentication.name)")
    public Order getOrder(Long orderId) {
        return orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
    }

    // Only admins can cancel orders directly
    @PreAuthorize("hasRole('ADMIN')")
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.setStatus(OrderStatus.CANCELLED);
        orderRepository.save(order);
    }

    // Users can request cancellation for their own unshipped orders; admins can cancel any
    @PreAuthorize("hasRole('ADMIN') or " +
            "(@orderAuthorizationService.canAccess(#orderId, authentication.name) and " +
            "@orderAuthorizationService.isCancellable(#orderId))")
    public void requestCancellation(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.setStatus(OrderStatus.CANCELLATION_REQUESTED);
        orderRepository.save(order);
    }
}

Complex SpEL expressions get hard to read and test. When our authorization logic outgrows simple conditions, we should pull it into methods on the authorization service.

Custom Security Expressions

For authorization patterns we use often, we can create custom method security expressions:

package com.example.security;

import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.core.Authentication;

public class CustomMethodSecurityExpressionHandler
        extends DefaultMethodSecurityExpressionHandler {

    private final AuthenticationTrustResolver trustResolver =
            new AuthenticationTrustResolverImpl();

    @Override
    protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
            Authentication authentication, MethodInvocation invocation) {

        CustomMethodSecurityExpressionRoot root =
                new CustomMethodSecurityExpressionRoot(authentication);
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setTrustResolver(this.trustResolver);
        root.setRoleHierarchy(getRoleHierarchy());
        return root;
    }
}
package com.example.security;

import org.springframework.security.access.expression.SecurityExpressionRoot;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;

public class CustomMethodSecurityExpressionRoot
        extends SecurityExpressionRoot
        implements MethodSecurityExpressionOperations {

    private Object filterObject;
    private Object returnObject;

    public CustomMethodSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }

    public boolean isResourceOwner(String resourceOwnerUsername) {
        return getAuthentication().getName().equals(resourceOwnerUsername);
    }

    public boolean canAccessTenant(Long tenantId) {
        Object principal = getAuthentication().getPrincipal();
        if (principal instanceof TenantAwareUserDetails user) {
            return user.getTenantId().equals(tenantId);
        }
        return false;
    }

    @Override
    public void setFilterObject(Object filterObject) {
        this.filterObject = filterObject;
    }

    @Override
    public Object getFilterObject() {
        return filterObject;
    }

    @Override
    public void setReturnObject(Object returnObject) {
        this.returnObject = returnObject;
    }

    @Override
    public Object getReturnObject() {
        return returnObject;
    }

    @Override
    public Object getThis() {
        return this;
    }
}

Register the custom handler in configuration:

package com.example.config;

import com.example.security.CustomMethodSecurityExpressionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
        return new CustomMethodSecurityExpressionHandler();
    }
}

Now we can use the custom expressions in annotations:

@PreAuthorize("isResourceOwner(#document.owner.username)")
public void updateDocument(Document document) {
    // Method implementation
}

@PreAuthorize("canAccessTenant(#tenantId)")
public List<Report> getReportsForTenant(Long tenantId) {
    // Method implementation
}

6. Common Bypass Techniques

Understanding how attackers get around access controls helps us build more solid implementations.

Parameter Pollution

Attackers send multiple values for the same parameter:

GET /api/orders?orderId=12345&orderId=99999

Different frameworks handle duplicate parameters differently. Some grab the first value, some grab the last, and some return arrays. If your authorization check uses one value while the data access uses another, you have a bypass.

Mitigation: Make sure parameter handling stays consistent through the entire request. Spring typically takes the first value during type conversion. Be explicit about handling arrays when your endpoint expects them.

HTTP Method Override

Some frameworks support method override headers:

POST /api/orders/12345
X-HTTP-Method-Override: DELETE

If your authorization rules vary by HTTP method and the framework respects the override, attackers can slip past restrictions.

Mitigation: Turn off method override headers in production, or make sure authorization applies to the effective method:

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.HiddenHttpMethodFilter;

@Configuration
public class WebConfig {

    @Bean
    public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
        HiddenHttpMethodFilter filter = new HiddenHttpMethodFilter();
        // Only allow method override from form fields, not headers
        return filter;
    }
}

Encoding Bypass

Attackers use URL encoding to dodge pattern matching:

/api/orders/12345
/api/orders/1234%35
/api/orders/%31%32%33%34%35

Mitigation: Spring Security's StrictHttpFirewall rejects URLs with encoded slashes and other suspicious patterns. Make sure it is active and you have not swapped it out for something more permissive.

JSON Property Manipulation

When deserializing JSON, attackers might sneak in properties the endpoint does not expect:

{
    "content": "Hello",
    "conversationId": 12345,
    "userId": 99999
}

If the userId field exists on the entity and gets bound during deserialization, the attacker controls who the message appears to come from.

Mitigation: Use DTOs that only include fields the endpoint should accept. Never bind request bodies directly to entities:

package com.example.messages.dto;

// Only includes fields the client should provide
public record SendMessageRequest(
        Long conversationId,
        String content
) {}
@PostMapping
public ResponseEntity<Message> sendMessage(
        @RequestBody SendMessageRequest request,
        @AuthenticationPrincipal UserDetails userDetails) {

    // userId comes from authenticated context, not from the request
    Message message = new Message();
    message.setConversationId(request.conversationId());
    message.setContent(request.content());
    message.setSenderId(userService.findByUsername(userDetails.getUsername()).getId());

    return ResponseEntity.ok(messageService.save(message));
}

Race Conditions

Time-of-check to time-of-use vulnerabilities happen when authorization and data access are separate operations:

// VULNERABLE: Race condition between check and use
public void deleteDocument(Long documentId, String username) {
    // Time of check
    Document doc = documentRepository.findById(documentId)
            .orElseThrow(() -> new NotFoundException());

    if (!doc.getOwner().getUsername().equals(username)) {
        throw new AccessDeniedException("Not owner");
    }

    // Time of use - document ownership might have changed
    documentRepository.delete(doc);
}

Between the ownership check and the delete, document ownership could change if your application allows ownership transfers.

Mitigation: Perform authorization and action atomically:

public void deleteDocument(Long documentId, String username) {
    int deleted = documentRepository.deleteByIdAndOwnerUsername(documentId, username);
    if (deleted == 0) {
        throw new NotFoundException();
    }
}

The single query either deletes the document when it exists and belongs to the user, or it affects zero rows. There is no window for race conditions.

7. Implementing Consistent Authorization

Authorization needs to be consistent across every path to a resource. Inconsistency opens bypass opportunities.

The Service Layer Pattern

Put authorization logic in the service layer, not the controller:

package com.example.documents.controller;

import com.example.documents.dto.DocumentResponse;
import com.example.documents.service.DocumentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/documents")
public class DocumentController {

    private final DocumentService documentService;

    public DocumentController(DocumentService documentService) {
        this.documentService = documentService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<DocumentResponse> getDocument(@PathVariable Long id) {
        // Controller just passes through; service handles authorization
        return ResponseEntity.ok(documentService.getDocument(id));
    }
}
package com.example.documents.service;

import com.example.documents.dto.DocumentResponse;
import com.example.documents.model.Document;
import com.example.documents.repository.DocumentRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class DocumentService {

    private final DocumentRepository documentRepository;

    public DocumentService(DocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    @PreAuthorize("@documentAuthorizationService.canAccess(#id)")
    public DocumentResponse getDocument(Long id) {
        Document doc = documentRepository.findById(id)
                .orElseThrow(() -> new DocumentNotFoundException(id));
        return DocumentResponse.from(doc);
    }
}

When authorization lives in the service layer, every caller gets the same checks. REST controllers, GraphQL resolvers, message handlers, scheduled tasks: they all go through the same authorization. Controller-level authorization means duplicating checks across multiple entry points.

Repository Query Methods

Another approach embeds authorization directly in repository queries:

package com.example.documents.repository;

import com.example.documents.model.Document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface DocumentRepository extends JpaRepository<Document, Long> {

    // Always include user constraint in queries
    Optional<Document> findByIdAndOwnerUsername(Long id, String username);

    List<Document> findByOwnerUsernameOrderByCreatedAtDesc(String username);

    @Query("SELECT d FROM Document d WHERE d.id = :id AND " +
           "(d.owner.username = :username OR d.sharedWith LIKE %:username%)")
    Optional<Document> findByIdAndAccessibleBy(Long id, String username);

    // Avoid exposing methods without user constraints
    // Optional<Document> findById(Long id); // Inherited but should not be used directly
}

This works well for simple ownership models. Complex authorization involving shared documents, team access, or hierarchical permissions may need the authorization service approach instead.

8. Frontend Considerations

Frontend code cannot enforce authorization. It can only make the user experience better by hiding options users should not see. The server stays the security boundary.

Do Not Trust Client-Side State

// INSECURE: Using client-stored userId
const userId = localStorage.getItem('userId');
const response = await fetch(`/api/users/${userId}/orders`);

An attacker modifies localStorage or intercepts the request. The server must verify the authenticated user rather than trusting a client-provided ID.

Let the Server Determine Context

// SECURE: Server determines user from session or token
const response = await fetch('/api/orders', {
    credentials: 'include'
});

The server extracts user identity from the session cookie or JWT. The client never specifies which user's data to retrieve.

Handling 403 Responses

Build your frontend to handle authorization failures gracefully:

interface ApiError {
    status: number;
    message: string;
}

async function fetchDocument(documentId: string): Promise<Document | null> {
    try {
        const response = await fetch(`/api/documents/${documentId}`, {
            credentials: 'include'
        });

        if (response.status === 404) {
            // Could be "not found" or "not authorized" - ambiguity is intentional
            return null;
        }

        if (response.status === 403) {
            // Explicit access denied
            throw new Error('You do not have permission to view this document');
        }

        if (!response.ok) {
            throw new Error('Failed to load document');
        }

        return await response.json();
    } catch (error) {
        console.error('Document fetch failed:', error);
        throw error;
    }
}

Conditional UI Based on Permissions

Fetch permissions from the server instead of computing them on the client:

interface DocumentPermissions {
    canView: boolean;
    canEdit: boolean;
    canDelete: boolean;
    canShare: boolean;
}

interface DocumentWithPermissions {
    document: Document;
    permissions: DocumentPermissions;
}

async function fetchDocumentWithPermissions(
    documentId: string
): Promise<DocumentWithPermissions> {
    const response = await fetch(`/api/documents/${documentId}?include=permissions`, {
        credentials: 'include'
    });

    if (!response.ok) {
        throw new Error('Failed to load document');
    }

    return await response.json();
}

// In component
function DocumentView({ documentId }: { documentId: string }) {
    const [data, setData] = useState<DocumentWithPermissions | null>(null);

    useEffect(() => {
        fetchDocumentWithPermissions(documentId).then(setData);
    }, [documentId]);

    if (!data) return <Loading />;

    return (
        <div>
            <DocumentContent document={data.document} />
            {data.permissions.canEdit && <EditButton documentId={documentId} />}
            {data.permissions.canDelete && <DeleteButton documentId={documentId} />}
            {data.permissions.canShare && <ShareButton documentId={documentId} />}
        </div>
    );
}

The server computes permissions and returns them with the resource. The frontend renders UI conditionally based on those permissions, but the server still enforces the actual authorization when someone tries to take action.

Wrapping Part 1

Broken access control stays at the top of security vulnerability lists for good reason. Authorization is application-specific, has to be implemented everywhere, and fails silently. IDOR vulnerabilities are the most direct form of this problem: exposing references to internal objects without checking whether the requester should have access.

Key principles from this first part:

Always verify ownership. Whether the object reference comes from a path parameter, query parameter, request body, or header, the server must confirm the authenticated user has permission to access that specific resource.

UUIDs are not access control. Unpredictable identifiers might slow down enumeration, but they do not stop access when identifiers get discovered through other channels.

Use method-level security. Spring Security's @PreAuthorize and @PostAuthorize annotations make authorization rules explicit and enforceable at the service layer.

Authorization belongs in services, not controllers. Multiple entry points might call the same service. Authorization in the service layer means consistent enforcement.

Return 404 for unauthorized access. Distinguishing between "does not exist" and "not authorized" helps attackers figure out which resources are valid.

Frontend cannot enforce authorization. Hide UI elements users should not see, but always enforce authorization on the server.

Broken Access Control

Part 1 of 1

A deep dive into broken access control for Java and Spring applications. Covers IDOR and horizontal privilege escalation with ownership validation, vertical escalation using role and attribute based access control in Spring Security 6, and a security architect's perspective on threat modeling, compliance, and building authorization into the development process.