Skip to main content

Command Palette

Search for a command to run...

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

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

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

Part 1 covered horizontal privilege escalation, where users access resources that belong to other users at the same privilege level. We walked through IDOR vulnerabilities in path parameters, query parameters, request bodies, and headers. We built ownership validation patterns with Spring Security method-level authorization and looked at bypass techniques attackers commonly use.

This second part tackles vertical privilege escalation, where users reach functions or data meant for higher privilege levels. We will dig into role-based and attribute-based access control in Spring Security 6, vulnerabilities in multi-step processes, indirect object references as a defensive pattern, API-specific problems like mass assignment and field-level access control, and testing strategies that actually verify your access controls work.

Broken Access Control remains in the position of A01:2025 in the OWASP Top 10 for 2025. The security risks covered here are far more serious than horizontal escalation since they give the attacker administrator access rights or break down the barrier between tenants.

1. Vertical Privilege Escalation

Vertical privilege escalation happens when a user does things or sees data that should be reserved for people with higher privileges. A regular user poking around in admin panels, a customer seeing internal pricing structures, or a tenant admin messing with system-wide settings: these all count as vertical escalation.

The Vulnerable Pattern

Here is an admin endpoint missing proper authorization:

package com.example.admin.controller;

import com.example.admin.dto.UserListResponse;
import com.example.admin.service.AdminService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    private final AdminService adminService;

    public AdminController(AdminService adminService) {
        this.adminService = adminService;
    }

    // VULNERABLE: No role check - any authenticated user can access
    @GetMapping("/users")
    public ResponseEntity<UserListResponse> getAllUsers() {
        return ResponseEntity.ok(adminService.getAllUsers());
    }
}

The endpoint sits under /api/admin, which might make you think it requires admin access. But naming a path is not access control. Without explicit authorization, any logged-in user who stumbles onto this endpoint can pull the entire user list.

URL-Based Authorization

Spring Security lets you set up URL-based authorization through the security filter chain:

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/manager/**").hasAnyRole("MANAGER", "ADMIN")
                .requestMatchers("/api/user/**").authenticated()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

URL-based authorization works nicely for coarse-grained access control where whole path trees map to specific roles. But it has limits. Business logic often needs finer control than URL patterns can handle. A manager might need access to /api/orders/{id}, but only for orders in their department. URL patterns cannot express that kind of relationship.

Method-Level Authorization for Vertical Escalation

Method-level authorization gives you precise control over individual operations:

package com.example.admin.controller;

import com.example.admin.dto.SystemConfigRequest;
import com.example.admin.dto.SystemConfigResponse;
import com.example.admin.dto.UserListResponse;
import com.example.admin.service.AdminService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    private final AdminService adminService;

    public AdminController(AdminService adminService) {
        this.adminService = adminService;
    }

    @GetMapping("/users")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<UserListResponse> getAllUsers() {
        return ResponseEntity.ok(adminService.getAllUsers());
    }

    @PutMapping("/config")
    @PreAuthorize("hasRole('SUPER_ADMIN')")
    public ResponseEntity<SystemConfigResponse> updateSystemConfig(
            @RequestBody SystemConfigRequest request) {
        return ResponseEntity.ok(adminService.updateConfig(request));
    }

    @GetMapping("/audit-log")
    @PreAuthorize("hasAnyRole('ADMIN', 'AUDITOR')")
    public ResponseEntity<AuditLogResponse> getAuditLog() {
        return ResponseEntity.ok(adminService.getAuditLog());
    }
}

Method-level authorization shows up clearly in code review and you can test it directly. When authorization fails, Spring Security sends back a 403 Forbidden before the method body even runs.

Combining URL and Method-Level Authorization

Defense in depth means using both URL-based and method-level authorization together:

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // Coarse-grained: block non-admins from admin paths entirely
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

URL-based rules act as your first line of defense. If somehow an attacker gets past URL matching, method-level annotations create a second barrier. If a developer forgets method-level annotations, URL rules still protect the endpoint. Neither layer works perfectly alone, but together they give you resilience against configuration mistakes.

2. Role-Based vs Attribute-Based Access Control

Access control models shape how authorization decisions get made. Understanding the trade-offs helps you pick the right approach for your application.

Role-Based Access Control (RBAC)

RBAC attaches permissions to roles, then assigns roles to users. What a user can access depends entirely on which roles they hold.

package com.example.security;

public enum Permission {
    USER_READ,
    USER_WRITE,
    USER_DELETE,
    ORDER_READ,
    ORDER_WRITE,
    ORDER_CANCEL,
    REPORT_VIEW,
    REPORT_EXPORT,
    SYSTEM_CONFIG
}
package com.example.security;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import java.util.Set;
import java.util.stream.Collectors;

public enum Role {

    USER(Set.of(
        Permission.USER_READ,
        Permission.ORDER_READ,
        Permission.ORDER_WRITE
    )),

    MANAGER(Set.of(
        Permission.USER_READ,
        Permission.ORDER_READ,
        Permission.ORDER_WRITE,
        Permission.ORDER_CANCEL,
        Permission.REPORT_VIEW
    )),

    ADMIN(Set.of(
        Permission.USER_READ,
        Permission.USER_WRITE,
        Permission.USER_DELETE,
        Permission.ORDER_READ,
        Permission.ORDER_WRITE,
        Permission.ORDER_CANCEL,
        Permission.REPORT_VIEW,
        Permission.REPORT_EXPORT,
        Permission.SYSTEM_CONFIG
    ));

    private final Set<Permission> permissions;

    Role(Set<Permission> permissions) {
        this.permissions = permissions;
    }

    public Set<Permission> getPermissions() {
        return permissions;
    }

    public Set<GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> authorities = permissions.stream()
                .map(permission -> new SimpleGrantedAuthority(permission.name()))
                .collect(Collectors.toSet());
        authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name()));
        return authorities;
    }
}

Integrate with Spring Security's UserDetails:

package com.example.security;

import com.example.user.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

public class CustomUserDetails implements UserDetails {

    private final User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRole().getAuthorities();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return !user.isLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return user.isEnabled();
    }

    public User getUser() {
        return user;
    }
}

Use permission-based authorization in controllers:

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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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;
    }

    @DeleteMapping("/{orderId}/cancel")
    @PreAuthorize("hasAuthority('ORDER_CANCEL')")
    public ResponseEntity<Order> cancelOrder(@PathVariable Long orderId) {
        return ResponseEntity.ok(orderService.cancelOrder(orderId));
    }
}

RBAC is easy to implement and easy to understand. Users get roles. Roles contain permissions. Permissions control access. The model fits well when access decisions hinge only on who the user is, not on the specific resource they want to touch.

Attribute-Based Access Control (ABAC)

ABAC makes authorization decisions by looking at attributes of the user, the resource, the action, and the environment. This gives you much finer control but adds complexity.

Think about a document management system where access depends on several factors: the user's department, the document's classification level, the user's clearance, and whether the document's project is still active.

package com.example.documents.security;

import com.example.documents.model.Document;
import com.example.security.CustomUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component("documentAccessEvaluator")
public class DocumentAccessEvaluator {

    public boolean canRead(Authentication authentication, Document document) {
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

        // Check classification level
        if (document.getClassificationLevel() > userDetails.getUser().getClearanceLevel()) {
            return false;
        }

        // Check department membership for internal documents
        if (document.isInternal() &&
            !userDetails.getUser().getDepartmentId().equals(document.getDepartmentId())) {
            return false;
        }

        // Check project membership for project documents
        if (document.getProjectId() != null &&
            !isProjectMember(userDetails.getUser().getId(), document.getProjectId())) {
            return false;
        }

        return true;
    }

    public boolean canWrite(Authentication authentication, Document document) {
        if (!canRead(authentication, document)) {
            return false;
        }

        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

        // Only document owner or editors can write
        if (document.getOwnerId().equals(userDetails.getUser().getId())) {
            return true;
        }

        return document.getEditors().contains(userDetails.getUser().getId());
    }

    public boolean canDelete(Authentication authentication, Document document) {
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

        // Only owner can delete
        return document.getOwnerId().equals(userDetails.getUser().getId());
    }

    private boolean isProjectMember(Long userId, Long projectId) {
        // Query project membership
        return projectMembershipRepository.existsByUserIdAndProjectId(userId, projectId);
    }
}

Apply ABAC through Spring Security:

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.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class DocumentService {

    private final DocumentRepository documentRepository;

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

    @PostAuthorize("@documentAccessEvaluator.canRead(authentication, returnObject)")
    public Document getDocument(Long documentId) {
        return documentRepository.findById(documentId)
                .orElseThrow(() -> new DocumentNotFoundException(documentId));
    }

    @PreAuthorize("@documentAccessEvaluator.canWrite(authentication, @documentRepository.findById(#documentId).orElse(null))")
    public Document updateDocument(Long documentId, DocumentUpdateRequest request) {
        Document document = documentRepository.findById(documentId)
                .orElseThrow(() -> new DocumentNotFoundException(documentId));

        document.setTitle(request.title());
        document.setContent(request.content());

        return documentRepository.save(document);
    }

    @PreAuthorize("@documentAccessEvaluator.canDelete(authentication, @documentRepository.findById(#documentId).orElse(null))")
    public void deleteDocument(Long documentId) {
        Document document = documentRepository.findById(documentId)
                .orElseThrow(() -> new DocumentNotFoundException(documentId));
        documentRepository.delete(document);
    }
}

The @PreAuthorize annotation for update and delete shows a common pattern: grab the resource to check authorization before the method runs. This adds an extra database query but makes sure authorization happens before any changes occur.

Choosing Between RBAC and ABAC

RBAC fits well when access decisions rest solely on user identity and broad categories. "Admins can manage users" or "Managers can view reports" map naturally to RBAC.

ABAC becomes necessary when access depends on resource attributes, relationships between users and resources, or environmental context. "Users can edit documents they own or where they appear as editors" needs ABAC.

Many applications use both. RBAC handles coarse-grained system-wide permissions. ABAC handles fine-grained resource-specific decisions. The two models complement each other rather than competing.

3. Multi-Step Process Vulnerabilities

Most complex actions tend to have several phases: start wizard, configure options, verify all options, and finally approve. All the above phases need to make sure that the user is ready for the next phase and has sufficient permissions.

The Vulnerable Pattern

Here is a payment flow with problems:

package com.example.payments.controller;

import com.example.payments.dto.PaymentConfirmation;
import com.example.payments.dto.PaymentInitiation;
import com.example.payments.dto.PaymentReview;
import com.example.payments.service.PaymentService;
import org.springframework.http.ResponseEntity;
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/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    // Step 1: Initiate payment
    @PostMapping("/initiate")
    public ResponseEntity<PaymentInitiation> initiatePayment(
            @RequestBody PaymentInitiateRequest request) {
        return ResponseEntity.ok(paymentService.initiate(request));
    }

    // Step 2: Review payment details
    @PostMapping("/review")
    public ResponseEntity<PaymentReview> reviewPayment(
            @RequestBody PaymentReviewRequest request) {
        return ResponseEntity.ok(paymentService.review(request));
    }

    // VULNERABLE: Step 3 doesn't verify steps 1 and 2 completed
    @PostMapping("/confirm")
    public ResponseEntity<PaymentConfirmation> confirmPayment(
            @RequestBody PaymentConfirmRequest request) {
        return ResponseEntity.ok(paymentService.confirm(request));
    }
}

An attacker can jump straight to the confirm endpoint, skipping whatever validation or rate limiting the earlier steps provide.

State Machine Enforcement

Model the process as a state machine and enforce valid transitions:

package com.example.payments.model;

public enum PaymentState {
    INITIATED,
    REVIEWED,
    CONFIRMED,
    COMPLETED,
    CANCELLED,
    FAILED
}
package com.example.payments.model;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "payments")
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String transactionId;
    private Long userId;
    private BigDecimal amount;
    private String currency;

    @Enumerated(EnumType.STRING)
    private PaymentState state;

    private Instant initiatedAt;
    private Instant reviewedAt;
    private Instant confirmedAt;
    private Instant expiresAt;

    // Getters and setters omitted for brevity

    public boolean canTransitionTo(PaymentState newState) {
        return switch (this.state) {
            case INITIATED -> newState == PaymentState.REVIEWED ||
                              newState == PaymentState.CANCELLED;
            case REVIEWED -> newState == PaymentState.CONFIRMED ||
                             newState == PaymentState.CANCELLED;
            case CONFIRMED -> newState == PaymentState.COMPLETED ||
                              newState == PaymentState.FAILED;
            case COMPLETED, CANCELLED, FAILED -> false;
        };
    }
}
package com.example.payments.service;

import com.example.payments.exception.InvalidStateTransitionException;
import com.example.payments.exception.PaymentExpiredException;
import com.example.payments.exception.PaymentNotFoundException;
import com.example.payments.model.Payment;
import com.example.payments.model.PaymentState;
import com.example.payments.repository.PaymentRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;

@Service
public class PaymentService {

    private final PaymentRepository paymentRepository;

    public PaymentService(PaymentRepository paymentRepository) {
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public Payment initiate(PaymentInitiateRequest request, String username) {
        Payment payment = new Payment();
        payment.setTransactionId(generateTransactionId());
        payment.setUserId(getUserId(username));
        payment.setAmount(request.amount());
        payment.setCurrency(request.currency());
        payment.setState(PaymentState.INITIATED);
        payment.setInitiatedAt(Instant.now());
        payment.setExpiresAt(Instant.now().plusSeconds(900)); // 15 minute expiry

        return paymentRepository.save(payment);
    }

    @Transactional
    @PreAuthorize("@paymentAuthorizationService.isOwner(#transactionId, authentication.name)")
    public Payment review(String transactionId) {
        Payment payment = findPayment(transactionId);

        validateNotExpired(payment);
        validateTransition(payment, PaymentState.REVIEWED);

        payment.setState(PaymentState.REVIEWED);
        payment.setReviewedAt(Instant.now());

        return paymentRepository.save(payment);
    }

    @Transactional
    @PreAuthorize("@paymentAuthorizationService.isOwner(#transactionId, authentication.name)")
    public Payment confirm(String transactionId) {
        Payment payment = findPayment(transactionId);

        validateNotExpired(payment);
        validateTransition(payment, PaymentState.CONFIRMED);

        payment.setState(PaymentState.CONFIRMED);
        payment.setConfirmedAt(Instant.now());

        // Process the actual payment...

        return paymentRepository.save(payment);
    }

    private Payment findPayment(String transactionId) {
        return paymentRepository.findByTransactionId(transactionId)
                .orElseThrow(() -> new PaymentNotFoundException(transactionId));
    }

    private void validateNotExpired(Payment payment) {
        if (payment.getExpiresAt().isBefore(Instant.now())) {
            throw new PaymentExpiredException(payment.getTransactionId());
        }
    }

    private void validateTransition(Payment payment, PaymentState targetState) {
        if (!payment.canTransitionTo(targetState)) {
            throw new InvalidStateTransitionException(
                    payment.getState(), targetState, payment.getTransactionId());
        }
    }

    private String generateTransactionId() {
        return java.util.UUID.randomUUID().toString();
    }

    private Long getUserId(String username) {
        // Resolve username to user ID
        return userRepository.findByUsername(username)
                .map(User::getId)
                .orElseThrow(() -> new UserNotFoundException(username));
    }
}

This implementation has a few key elements working together. State machine validation ensures each step checks whether the payment is in the right state before moving forward. Ownership verification through @PreAuthorize confirms the authenticated user owns this payment. Time-based expiration stops abandoned workflows from getting completed days later. And @Transactional wrapping makes state changes atomic.

Cryptographic Step Tokens

For stateless architectures, cryptographic tokens can prove step completion:

package com.example.payments.security;

import com.example.payments.model.PaymentState;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;

@Service
public class StepTokenService {

    private final SecretKey signingKey;

    public StepTokenService(@Value("${app.step-token.secret}") String secret) {
        this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String generateStepToken(String transactionId, PaymentState completedStep,
                                    String username) {
        Instant now = Instant.now();

        return Jwts.builder()
                .subject(transactionId)
                .claim("step", completedStep.name())
                .claim("user", username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(900)))
                .signWith(signingKey)
                .compact();
    }

    public StepTokenClaims validateStepToken(String token, PaymentState requiredStep,
                                             String username) {
        Claims claims = Jwts.parser()
                .verifyWith(signingKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();

        String tokenUser = claims.get("user", String.class);
        if (!username.equals(tokenUser)) {
            throw new InvalidStepTokenException("Token user mismatch");
        }

        PaymentState tokenStep = PaymentState.valueOf(claims.get("step", String.class));
        if (!isValidPrecursor(tokenStep, requiredStep)) {
            throw new InvalidStepTokenException(
                    "Invalid step sequence: " + tokenStep + " -> " + requiredStep);
        }

        return new StepTokenClaims(claims.getSubject(), tokenStep, tokenUser);
    }

    private boolean isValidPrecursor(PaymentState completed, PaymentState required) {
        return switch (required) {
            case REVIEWED -> completed == PaymentState.INITIATED;
            case CONFIRMED -> completed == PaymentState.REVIEWED;
            default -> false;
        };
    }
}
package com.example.payments.security;

import com.example.payments.model.PaymentState;

public record StepTokenClaims(
        String transactionId,
        PaymentState completedStep,
        String username
) {}

The client gets a step token after finishing each step and has to present it to move to the next step. The token cryptographically proves the previous step was completed by the same user.

4. Indirect Object References

Indirect object references swap direct database IDs for opaque tokens that the application maps back to real resources. This adds a defense layer against IDOR while you still maintain proper authorization checks.

Reference Map Pattern

package com.example.security;

import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;

import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
@SessionScope
public class IndirectReferenceMap {

    private final Map<String, Long> tokenToId = new ConcurrentHashMap<>();
    private final Map<Long, String> idToToken = new ConcurrentHashMap<>();
    private final SecureRandom secureRandom = new SecureRandom();

    public String getToken(Long directId) {
        return idToToken.computeIfAbsent(directId, id -> {
            String token = generateToken();
            tokenToId.put(token, id);
            return token;
        });
    }

    public Long getDirectId(String token) {
        Long id = tokenToId.get(token);
        if (id == null) {
            throw new InvalidReferenceException("Invalid reference token");
        }
        return id;
    }

    private String generateToken() {
        byte[] bytes = new byte[16];
        secureRandom.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
}

Use the reference map in controllers:

package com.example.documents.controller;

import com.example.documents.dto.DocumentResponse;
import com.example.documents.model.Document;
import com.example.documents.service.DocumentService;
import com.example.security.IndirectReferenceMap;
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;
    private final IndirectReferenceMap referenceMap;

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

    @GetMapping("/{token}")
    public ResponseEntity<DocumentResponse> getDocument(@PathVariable String token) {
        Long documentId = referenceMap.getDirectId(token);
        Document document = documentService.getDocument(documentId);
        return ResponseEntity.ok(DocumentResponse.from(document, referenceMap));
    }
}
package com.example.documents.dto;

import com.example.documents.model.Document;
import com.example.security.IndirectReferenceMap;

public record DocumentResponse(
        String id,
        String title,
        String content,
        String ownerId
) {
    public static DocumentResponse from(Document document, IndirectReferenceMap referenceMap) {
        return new DocumentResponse(
                referenceMap.getToken(document.getId()),
                document.getTitle(),
                document.getContent(),
                referenceMap.getToken(document.getOwner().getId())
        );
    }
}

Trade-offs of Indirect References

Indirect references have multiple benefits. First of all, internal database structure is hidden, making it difficult to understand data models. Second, session-scoped maps allow tokens to be usable only for sessions that issued them. Third, enumeration is pointless due to the absence of any token pattern.

However, there are some disadvantages of the strategy. Statelessness of APIs based on JWTs is violated by using session-scoped maps. Bookmarking and URL sharing do not work anymore as each session generates new tokens. Application architecture becomes more complex, and debugging is harder due to the inability to resolve database IDs.

It should be mentioned that indirect references introduce the concept of defense in depth. They are not meant to be an alternative to authorization logic. In case an attacker manages to obtain valid tokens with the help of session hijacking or any other means, authorization logic must prevent access anyway.

5. API-Specific Access Control Issues

Modern APIs bring access control challenges that go beyond traditional web applications.

Mass Assignment Vulnerabilities

Mass assignment happens when an API automatically binds request fields to object properties without filtering. An attacker can slip in fields they should not be allowed to change.

package com.example.users.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String email;
    private String password;
    private String role;        // Should not be user-modifiable
    private boolean verified;   // Should not be user-modifiable
    private boolean admin;      // Should not be user-modifiable

    // Getters and setters
}
// VULNERABLE: Directly binding request to entity
@PutMapping("/profile")
public ResponseEntity<User> updateProfile(@RequestBody User user) {
    return ResponseEntity.ok(userService.update(user));
}

An attacker sends:

{
    "username": "attacker",
    "email": "attacker@evil.com",
    "admin": true,
    "role": "ADMIN"
}

If the framework binds every JSON property to the entity, the attacker just gave themselves admin privileges.

Secure Implementation:

Use DTOs that only include fields users should be allowed to change:

package com.example.users.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;

public record ProfileUpdateRequest(
        @Size(min = 3, max = 50) String username,
        @Email String email
) {}
package com.example.users.controller;

import com.example.users.dto.ProfileUpdateRequest;
import com.example.users.dto.UserResponse;
import com.example.users.service.UserService;
import jakarta.validation.Valid;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/profile")
public class ProfileController {

    private final UserService userService;

    public ProfileController(UserService userService) {
        this.userService = userService;
    }

    @PutMapping
    public ResponseEntity<UserResponse> updateProfile(
            @Valid @RequestBody ProfileUpdateRequest request,
            @AuthenticationPrincipal UserDetails userDetails) {

        return ResponseEntity.ok(
                userService.updateProfile(userDetails.getUsername(), request));
    }
}
package com.example.users.service;

import com.example.users.dto.ProfileUpdateRequest;
import com.example.users.dto.UserResponse;
import com.example.users.model.User;
import com.example.users.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public UserResponse updateProfile(String username, ProfileUpdateRequest request) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UserNotFoundException(username));

        // Only update allowed fields
        if (request.username() != null) {
            user.setUsername(request.username());
        }
        if (request.email() != null) {
            user.setEmail(request.email());
        }

        return UserResponse.from(userRepository.save(user));
    }
}

The DTO spells out exactly which fields can be updated. Sensitive fields like role, admin, and verified simply do not exist in the DTO and cannot be touched through this endpoint.

Field-Level Access Control

Different users might need to see different fields of the same resource:

package com.example.users.dto;

import com.example.users.model.User;
import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record UserResponse(
        Long id,
        String username,
        String email,          // Visible to self and admins
        String phoneNumber,    // Visible to self and admins
        String role,           // Visible to admins only
        Boolean verified,      // Visible to admins only
        String createdAt
) {
    public static UserResponse forSelf(User user) {
        return new UserResponse(
                user.getId(),
                user.getUsername(),
                user.getEmail(),
                user.getPhoneNumber(),
                null,  // Hide role from regular users
                null,  // Hide verification status
                user.getCreatedAt().toString()
        );
    }

    public static UserResponse forAdmin(User user) {
        return new UserResponse(
                user.getId(),
                user.getUsername(),
                user.getEmail(),
                user.getPhoneNumber(),
                user.getRole(),
                user.isVerified(),
                user.getCreatedAt().toString()
        );
    }

    public static UserResponse forPublic(User user) {
        return new UserResponse(
                user.getId(),
                user.getUsername(),
                null,  // Hide email
                null,  // Hide phone
                null,
                null,
                null
        );
    }
}
package com.example.users.controller;

import com.example.users.dto.UserResponse;
import com.example.users.model.User;
import com.example.users.service.UserService;
import com.example.security.CustomUserDetails;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{userId}")
    public ResponseEntity<UserResponse> getUser(
            @PathVariable Long userId,
            @AuthenticationPrincipal CustomUserDetails userDetails) {

        User user = userService.findById(userId);

        if (userDetails.getUser().isAdmin()) {
            return ResponseEntity.ok(UserResponse.forAdmin(user));
        }

        if (userDetails.getUser().getId().equals(userId)) {
            return ResponseEntity.ok(UserResponse.forSelf(user));
        }

        return ResponseEntity.ok(UserResponse.forPublic(user));
    }
}

The response content changes based on the relationship between whoever is asking and the resource. Admins see everything. Users see their own sensitive fields. Everyone else gets only public information.

GraphQL-Specific Considerations

GraphQL APIs create unique access control headaches because clients control query structure:

query {
  user(id: "123") {
    username
    email
    role           # Should this be visible?
    orders {
      id
      total
      creditCard {  # Definitely should not be visible
        number
        cvv
      }
    }
  }
}

Field-level authorization becomes essential. Implement authorization directives or resolver-level checks:

package com.example.graphql.security;

import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;

public class SecureDataFetcher<T> implements DataFetcher<T> {

    private final DataFetcher<T> delegate;
    private final String requiredAuthority;

    public SecureDataFetcher(DataFetcher<T> delegate, String requiredAuthority) {
        this.delegate = delegate;
        this.requiredAuthority = requiredAuthority;
    }

    @Override
    public T get(DataFetchingEnvironment environment) throws Exception {
        var authentication = SecurityContextHolder.getContext().getAuthentication();

        boolean hasAuthority = authentication.getAuthorities().stream()
                .anyMatch(auth -> auth.getAuthority().equals(requiredAuthority));

        if (!hasAuthority) {
            throw new AccessDeniedException(
                    "Access denied to field: " + environment.getField().getName());
        }

        return delegate.get(environment);
    }
}

6. Testing Access Control

Access control testing will tell you whether your authorization requirements function properly. You must test your requirements not only for successful cases when access is granted but also for unsuccessful ones when access is denied.

Unit Testing Authorization Logic

Test your authorization services in isolation:

package com.example.documents.security;

import com.example.documents.model.Document;
import com.example.documents.repository.ProjectMembershipRepository;
import com.example.security.CustomUserDetails;
import com.example.user.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class DocumentAccessEvaluatorTest {

    @Mock
    private ProjectMembershipRepository projectMembershipRepository;

    private DocumentAccessEvaluator evaluator;

    @BeforeEach
    void setUp() {
        evaluator = new DocumentAccessEvaluator(projectMembershipRepository);
    }

    @Test
    void canRead_ownerCanAccessOwnDocument() {
        User owner = createUser(1L, "owner", 3, 100L);
        Document document = createDocument(1L, owner.getId(), 2, 100L, null);
        Authentication auth = createAuthentication(owner);

        assertThat(evaluator.canRead(auth, document)).isTrue();
    }

    @Test
    void canRead_userCannotAccessHigherClassification() {
        User user = createUser(1L, "user", 2, 100L);  // Clearance level 2
        Document document = createDocument(1L, 999L, 3, 100L, null);  // Classification 3
        Authentication auth = createAuthentication(user);

        assertThat(evaluator.canRead(auth, document)).isFalse();
    }

    @Test
    void canRead_userCannotAccessOtherDepartmentInternalDoc() {
        User user = createUser(1L, "user", 3, 100L);  // Department 100
        Document document = createInternalDocument(1L, 999L, 1, 200L);  // Department 200
        Authentication auth = createAuthentication(user);

        assertThat(evaluator.canRead(auth, document)).isFalse();
    }

    @Test
    void canRead_projectMemberCanAccessProjectDocument() {
        User user = createUser(1L, "user", 3, 100L);
        Document document = createDocument(1L, 999L, 1, 100L, 50L);  // Project 50
        Authentication auth = createAuthentication(user);

        when(projectMembershipRepository.existsByUserIdAndProjectId(1L, 50L))
                .thenReturn(true);

        assertThat(evaluator.canRead(auth, document)).isTrue();
    }

    @Test
    void canWrite_onlyOwnerOrEditorCanWrite() {
        User user = createUser(1L, "user", 3, 100L);
        Document document = createDocument(1L, 999L, 1, 100L, null);
        document.getEditors().add(1L);
        Authentication auth = createAuthentication(user);

        assertThat(evaluator.canWrite(auth, document)).isTrue();
    }

    @Test
    void canDelete_onlyOwnerCanDelete() {
        User nonOwner = createUser(1L, "user", 3, 100L);
        Document document = createDocument(1L, 999L, 1, 100L, null);
        Authentication auth = createAuthentication(nonOwner);

        assertThat(evaluator.canDelete(auth, document)).isFalse();
    }

    private User createUser(Long id, String username, int clearance, Long deptId) {
        User user = new User();
        user.setId(id);
        user.setUsername(username);
        user.setClearanceLevel(clearance);
        user.setDepartmentId(deptId);
        return user;
    }

    private Document createDocument(Long id, Long ownerId, int classification,
                                    Long deptId, Long projectId) {
        Document doc = new Document();
        doc.setId(id);
        doc.setOwnerId(ownerId);
        doc.setClassificationLevel(classification);
        doc.setDepartmentId(deptId);
        doc.setProjectId(projectId);
        doc.setInternal(false);
        return doc;
    }

    private Document createInternalDocument(Long id, Long ownerId,
                                            int classification, Long deptId) {
        Document doc = createDocument(id, ownerId, classification, deptId, null);
        doc.setInternal(true);
        return doc;
    }

    private Authentication createAuthentication(User user) {
        CustomUserDetails userDetails = new CustomUserDetails(user);
        return new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());
    }
}

Integration Testing with Spring Security

Test the complete authorization flow including Spring Security:

package com.example.documents.controller;

import com.example.documents.model.Document;
import com.example.documents.repository.DocumentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class DocumentControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private DocumentRepository documentRepository;

    private Document userDocument;
    private Document adminDocument;

    @BeforeEach
    void setUp() {
        documentRepository.deleteAll();

        userDocument = new Document();
        userDocument.setTitle("User Doc");
        userDocument.setOwnerUsername("testuser");
        userDocument = documentRepository.save(userDocument);

        adminDocument = new Document();
        adminDocument.setTitle("Admin Doc");
        adminDocument.setOwnerUsername("admin");
        adminDocument = documentRepository.save(adminDocument);
    }

    @Test
    @WithMockUser(username = "testuser", roles = "USER")
    void getDocument_userCanAccessOwnDocument() throws Exception {
        mockMvc.perform(get("/api/documents/" + userDocument.getId()))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "testuser", roles = "USER")
    void getDocument_userCannotAccessOtherUserDocument() throws Exception {
        mockMvc.perform(get("/api/documents/" + adminDocument.getId()))
                .andExpect(status().isNotFound());
    }

    @Test
    @WithMockUser(username = "admin", roles = "ADMIN")
    void getDocument_adminCanAccessAnyDocument() throws Exception {
        mockMvc.perform(get("/api/documents/" + userDocument.getId()))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "testuser", roles = "USER")
    void deleteDocument_userCanDeleteOwnDocument() throws Exception {
        mockMvc.perform(delete("/api/documents/" + userDocument.getId()))
                .andExpect(status().isNoContent());
    }

    @Test
    @WithMockUser(username = "otheruser", roles = "USER")
    void deleteDocument_userCannotDeleteOtherUserDocument() throws Exception {
        mockMvc.perform(delete("/api/documents/" + userDocument.getId()))
                .andExpect(status().isForbidden());
    }

    @Test
    void getDocument_unauthenticatedUserReceives401() throws Exception {
        mockMvc.perform(get("/api/documents/" + userDocument.getId()))
                .andExpect(status().isUnauthorized());
    }
}

Custom Security Test Annotations

Create reusable test annotations for common authorization scenarios:

package com.example.test.security;

import org.springframework.security.test.context.support.WithSecurityContext;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
    String username() default "testuser";
    String role() default "USER";
    long userId() default 1L;
    int clearanceLevel() default 1;
    long departmentId() default 100L;
}
package com.example.test.security;

import com.example.security.CustomUserDetails;
import com.example.user.model.User;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.support.WithSecurityContextFactory;

public class WithMockCustomUserSecurityContextFactory
        implements WithSecurityContextFactory<WithMockCustomUser> {

    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        User user = new User();
        user.setId(annotation.userId());
        user.setUsername(annotation.username());
        user.setRole(annotation.role());
        user.setClearanceLevel(annotation.clearanceLevel());
        user.setDepartmentId(annotation.departmentId());

        CustomUserDetails userDetails = new CustomUserDetails(user);

        UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());

        context.setAuthentication(auth);
        return context;
    }
}

Use the custom annotation in tests:

@Test
@WithMockCustomUser(username = "highclearance", clearanceLevel = 5, departmentId = 100)
void getDocument_highClearanceUserCanAccessClassifiedDocs() throws Exception {
    mockMvc.perform(get("/api/documents/" + classifiedDocument.getId()))
            .andExpect(status().isOk());
}

Automated Security Scanning with Semgrep

Build Semgrep rules to catch missing authorization checks:

rules:
  # ---------------------------------------------------------------------------
  # 1. Controller endpoint exposed without a method-level authorization check.
  #    CWE-862: Missing Authorization
  # ---------------------------------------------------------------------------
  - id: spring-controller-missing-authorization
    patterns:
      - pattern-inside: |
          @RestController
          class $CTRL { ... }
      - pattern: |
          @$MAPPING(...)
          \(RET \)METHOD(...) { ... }
      - metavariable-regex:
          metavariable: $MAPPING
          regex: ^(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)$
      - pattern-not: |
          @PreAuthorize(...)
          \(RET \)METHOD(...) { ... }
      - pattern-not: |
          @PostAuthorize(...)
          \(RET \)METHOD(...) { ... }
      - pattern-not: |
          @Secured(...)
          \(RET \)METHOD(...) { ... }
      - pattern-not: |
          @RolesAllowed(...)
          \(RET \)METHOD(...) { ... }
    message: >
      Controller method $METHOD has a request mapping but no method-level
      authorization annotation. Confirm access control is enforced elsewhere,
      or add @PreAuthorize / @Secured / @RolesAllowed.
    languages: [java]
    severity: WARNING
    metadata:
      category: security
      cwe: "CWE-862: Missing Authorization"
      confidence: MEDIUM

  # ---------------------------------------------------------------------------
  # 2. Service method that changes state without a method-level guard.
  #    CWE-862: Missing Authorization
  # ---------------------------------------------------------------------------
  - id: spring-service-sensitive-operation-missing-authorization
    patterns:
      - pattern-inside: |
          @Service
          class $SVC { ... }
      - pattern: |
          public \(RET \)METHOD(...) { ... }
      - metavariable-regex:
          metavariable: $METHOD
          regex: ^(delete|update|create|remove|save|disable|enable|grant|revoke|reset).*
      - pattern-not: |
          @PreAuthorize(...)
          public \(RET \)METHOD(...) { ... }
      - pattern-not: |
          @PostAuthorize(...)
          public \(RET \)METHOD(...) { ... }
      - pattern-not: |
          @Secured(...)
          public \(RET \)METHOD(...) { ... }
    message: >
      Service method $METHOD performs a state-changing operation without a
      method-level authorization check. If this is reachable from an
      under-authorized path it may allow privilege escalation. Add
      @PreAuthorize or verify a guard exists upstream.
    languages: [java]
    severity: WARNING
    metadata:
      category: security
      cwe: "CWE-862: Missing Authorization"
      confidence: MEDIUM

  # ---------------------------------------------------------------------------
  # 3. User-controlled identifier reaching a repository lookup with no
  #    ownership scoping (taint mode). CWE-639: IDOR.
  # ---------------------------------------------------------------------------
  - id: spring-idor-user-controlled-id-to-repository
    mode: taint
    pattern-sources:
      - patterns:
          - pattern-either:
              - pattern: \(RET \)M(..., @PathVariable \(T \)SRC, ...) { ... }
              - pattern: \(RET \)M(..., @PathVariable(...) \(T \)SRC, ...) { ... }
              - pattern: \(RET \)M(..., @RequestParam \(T \)SRC, ...) { ... }
              - pattern: \(RET \)M(..., @RequestParam(...) \(T \)SRC, ...) { ... }
          - focus-metavariable: $SRC
    pattern-sinks:
      - patterns:
          - pattern-either:
              - pattern: \(REPO.findById(\)SINK)
              - pattern: \(REPO.getReferenceById(\)SINK)
              - pattern: \(REPO.getOne(\)SINK)
              - pattern: \(REPO.deleteById(\)SINK)
              - pattern: \(REPO.existsById(\)SINK)
          - focus-metavariable: $SINK
    message: >
      A user-controlled identifier ($SRC) reaches a repository lookup with no
      ownership or tenant scoping. This is the classic IDOR shape (CWE-639):
      an authenticated user can substitute another user's identifier and read
      or modify records they do not own. Scope the query to the authenticated
      principal (for example findByIdAndOwnerId) or verify ownership before
      acting on the record.
    languages: [java]
    severity: WARNING
    metadata:
      category: security
      cwe: "CWE-639: Authorization Bypass Through User-Controlled Key"
      confidence: MEDIUM

Run these rules in CI/CD:

semgrep --config .semgrep/access-control.yml src/

7. Frontend Access Control Patterns

The frontend cannot enforce authorization, but it does play a part in showing appropriate interfaces and handling authorization failures smoothly.

Permission-Aware Components

Fetch permissions from the server and render UI conditionally:

import { useState, useEffect, createContext, useContext, ReactNode } from 'react';

interface Permissions {
    canViewUsers: boolean;
    canEditUsers: boolean;
    canDeleteUsers: boolean;
    canViewReports: boolean;
    canExportReports: boolean;
    canAccessAdmin: boolean;
}

interface PermissionsContextType {
    permissions: Permissions | null;
    loading: boolean;
    hasPermission: (permission: keyof Permissions) => boolean;
}

const PermissionsContext = createContext<PermissionsContextType | undefined>(undefined);

export function PermissionsProvider({ children }: { children: ReactNode }) {
    const [permissions, setPermissions] = useState<Permissions | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        async function fetchPermissions() {
            try {
                const response = await fetch('/api/me/permissions', {
                    credentials: 'include'
                });

                if (response.ok) {
                    const data = await response.json();
                    setPermissions(data);
                }
            } catch (error) {
                console.error('Failed to fetch permissions:', error);
            } finally {
                setLoading(false);
            }
        }

        fetchPermissions();
    }, []);

    const hasPermission = (permission: keyof Permissions): boolean => {
        return permissions?.[permission] ?? false;
    };

    return (
        <PermissionsContext.Provider value={{ permissions, loading, hasPermission }}>
            {children}
        </PermissionsContext.Provider>
    );
}

export function usePermissions(): PermissionsContextType {
    const context = useContext(PermissionsContext);
    if (context === undefined) {
        throw new Error('usePermissions must be used within a PermissionsProvider');
    }
    return context;
}
import { ReactNode } from 'react';
import { usePermissions, Permissions } from './PermissionsContext';

interface RequirePermissionProps {
    permission: keyof Permissions;
    children: ReactNode;
    fallback?: ReactNode;
}

export function RequirePermission({
    permission,
    children,
    fallback = null
}: RequirePermissionProps) {
    const { hasPermission, loading } = usePermissions();

    if (loading) {
        return null;
    }

    if (!hasPermission(permission)) {
        return <>{fallback}</>;
    }

    return <>{children}</>;
}

Use in components:

import { RequirePermission } from './RequirePermission';

export function UserManagement() {
    return (
        <div>
            <h1>User Management</h1>

            <UserList />

            <RequirePermission permission="canEditUsers">
                <EditUserButton />
            </RequirePermission>

            <RequirePermission permission="canDeleteUsers">
                <DeleteUserButton />
            </RequirePermission>

            <RequirePermission
                permission="canExportReports"
                fallback={<span>Export not available for your role</span>}
            >
                <ExportButton />
            </RequirePermission>
        </div>
    );
}

Handling 403 Responses

When the server rejects requests, handle the failure gracefully:

interface ApiClientConfig {
    baseUrl: string;
    onUnauthorized?: () => void;
    onForbidden?: (url: string) => void;
}

export function createApiClient(config: ApiClientConfig) {
    async function request<T>(
        endpoint: string,
        options: RequestInit = {}
    ): Promise<T> {
        const url = `\({config.baseUrl}\){endpoint}`;

        const response = await fetch(url, {
            ...options,
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            }
        });

        if (response.status === 401) {
            config.onUnauthorized?.();
            throw new UnauthorizedError('Authentication required');
        }

        if (response.status === 403) {
            config.onForbidden?.(endpoint);
            throw new ForbiddenError('You do not have permission for this action');
        }

        if (!response.ok) {
            throw new ApiError(`Request failed: ${response.status}`);
        }

        return response.json();
    }

    return { request };
}

class UnauthorizedError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'UnauthorizedError';
    }
}

class ForbiddenError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'ForbiddenError';
    }
}

class ApiError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'ApiError';
    }
}
const apiClient = createApiClient({
    baseUrl: '/api',
    onUnauthorized: () => {
        window.location.href = '/login';
    },
    onForbidden: (url) => {
        console.warn(`Access denied to: ${url}`);
        showNotification('You do not have permission to perform this action');
    }
});

Wrapping Part 2

This second part is about more complex access control patterns that go beyond simple IDOR vulnerability.

To prevent vertical privilege escalation, implement URL-based authorization as the first line of defense, and method-level @PreAuthorize annotations as the second one.

RBAC versus ABAC is the topic of two different authorization approaches. RBAC does well at coarse-grained, role-based decisions. ABAC performs best at fine-grained, attribute-based decisions.

Multi-step processes should have their workflow enforced by a state machine to prevent attackers from skipping some steps. Step tokens provide a stateless option for the enforcement.

Indirect object references allow hiding internal IDs by using opaque tokens to add an additional level of defense in depth but not authorization check replacement.

Mass assignment occurs when APIs bind the request parameters to the object's properties without filtering them. To prevent such a thing from happening, use DTOs to filter out only certain fields.

Field-level access control allows providing different sets of fields to different users. It is done by implementing multiple DTO projections depending on the requester's relation to the requested resource.

Testing needs to be done for all possible scenarios. Authorize decision making should be unit-tested, integration tested with Spring Security and Semgrep.

Broken Access Control

Part 2 of 2

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.

Start from the beginning

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

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