Sửa lỗi 'Access is denied' (403 Forbidden) trong Spring Security

intermediate Java2026-05-23| Java 8+, Spring Boot 2.x/3.x, Spring Security 5.x/6.x

Error Message

org.springframework.security.access.AccessDeniedException: Access is denied
#java#spring-boot#spring-security#phân quyền#403

Bế tắc 403 Forbidden

Đã 2 giờ sáng. Logic xác thực của bạn trông có vẻ hoàn hảo, JWT hợp lệ và người dùng chắc chắn đã đăng nhập. Tuy nhiên, console của bạn lại tràn ngập stack trace này:

org.springframework.security.access.AccessDeniedException: Access is denied
    at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73)
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:232)

Ở phía frontend, bạn thấy một lỗi 403 Forbidden lạnh lùng. Lỗi này là cách Spring Security nói rằng: "Tôi biết bạn là ai, nhưng bạn không có đúng chìa khóa cho cánh cửa này." Trong khi một AuthenticationException nghĩa là bạn đã thất bại khi đăng nhập, thì một AccessDeniedException nghĩa là mức độ phân quyền của bạn không đủ.

Nguyên nhân gốc rễ 1: Sự không khớp tiền tố "ROLE_"

Spring Security có quan điểm khá cụ thể về việc đặt tên role. Một vấn đề thường gặp xảy ra khi cơ sở dữ liệu của bạn lưu trữ các role như ADMIN, nhưng framework bảo mật lại mong đợi một tiền tố cụ thể.

Theo mặc định, biểu thức hasRole('ADMIN') sẽ kiểm tra một authority có tên là ROLE_ADMIN. Nếu cơ sở dữ liệu hoặc các claim trong JWT của bạn chỉ cung cấp ADMIN, việc kiểm tra sẽ thất bại trong âm thầm.

Cách khắc phục: Chuẩn hóa Authorities của bạn

Khi tải user authorities, hãy đảm bảo tiền tố được thêm vào thủ công nếu dữ liệu nguồn của bạn thiếu nó:

// Trong UserDetailsService của bạn
List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
    .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
    .collect(Collectors.toList());

return new org.springframework.security.core.userdetails.User(
    user.getEmail(), 
    user.getPassword(), 
    authorities
);

Nếu bạn muốn bỏ qua tiền tố, hãy sử dụng hasAuthority('ADMIN'). Nó sẽ khớp chính xác với chuỗi ký tự mà không tự động thêm ROLE_ đằng sau hậu trường.

Nguyên nhân gốc rễ 2: Method Security đang ở trạng thái ngủ

Có phải các annotation @PreAuthorize hoặc @Secured của bạn đang bị phớt lờ? Bạn có thể nhận lỗi access denied ngay cả trên các endpoint công khai vì logic xử lý các annotation đó chưa được khởi động.

Cách khắc phục: Đánh thức Method Security

Trong Spring Boot 3.x, bạn phải kích hoạt các kiểm tra này một cách rõ ràng trong class cấu hình bằng cách sử dụng @EnableMethodSecurity:

 @website/content/errors/ja/terraform/fixing-terraform-error-backend-configuration-changed-for-s3-and-gcs-backends.md @EnableWebSecurity @EnableMethodSecurity // Đây là bắt buộc để @PreAuthorize hoạt động
public class SecurityConfig {
    @website/content/errors/en/aws/fix-aws-elastic-beanstalk-deploy-failed-wsgipath-does-not-exist-health-check-sev.md
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // Logic cấu hình tại đây
        return http.build();
    }
}

Mẹo: Đối với Spring Boot 2.x, hãy sử dụng @EnableGlobalMethodSecurity(prePostEnabled = true) thay thế.

Nguyên nhân gốc rễ 3: CSRF chặn các request thay đổi trạng thái

Nếu các request GET hoạt động nhưng các lệnh gọi POST, PUT hoặc DELETE thất bại với lỗi 403, rào cản tiềm ẩn thường là bảo vệ CSRF (Cross-Site Request Forgery). Spring Security bật tính năng này theo mặc định để bảo vệ các ứng dụng dựa trên session.

Cách khắc phục: Cấu hình CSRF cho kiến trúc của bạn

Đối với các REST API stateless sử dụng JWT, bạn thường nên vô hiệu hóa CSRF. Vì bạn không sử dụng browser cookie để quản lý session, rủi ro đã được giảm thiểu:

 @website/content/errors/en/aws/fix-aws-elastic-beanstalk-deploy-failed-wsgipath-does-not-exist-health-check-sev.md
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable()) // An toàn cho các API dựa trên JWT stateless
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
        );
    return http.build();
}

Nếu bạn đang sử dụng session, đừng vô hiệu hóa nó. Thay vào đó, hãy đảm bảo frontend của bạn gửi header X-XSRF-TOKEN với mỗi request thay đổi dữ liệu.

Nguyên nhân gốc rễ 4: Filter Chain bị "che khuất"

Spring Security xử lý các matcher theo trình tự tuyến tính từ trên xuống dưới. Nếu một quy tắc rộng nằm ở trên cùng, nó sẽ "che khuất" (shadow) các quy tắc cụ thể hơn bên dưới nó.

// CẤU HÌNH BỊ LỖI
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/**").authenticated() // Cái này nuốt chửng mọi request!
    .requestMatchers("/api/admin/**").hasRole("ADMIN") // Đoạn code này không bao giờ được chạm tới
)

Cách khắc phục: Sắp xếp theo độ ưu tiên cụ thể

Hãy coi filter chain của bạn như một cái phễu. Đặt các đường dẫn hẹp nhất ở trên cùng và các quy tắc bao quát (catch-all) ở dưới cùng:

.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
    .anyRequest().authenticated() // Quy tắc bao quát nằm cuối cùng
)

Cách xác minh bản sửa lỗi

Đừng đoán mò nữa. Hãy bật debug logging trong application.properties để xem chính xác lý do tại sao một request bị từ chối:

logging.level.org.springframework.security=DEBUG

Tìm kiếm đầu ra của AuthorizationFilter trong log của bạn. Nó sẽ hiển thị một so sánh như thế này:

DEBUG o.s.s.w.a.i.FilterSecurityInterceptor - Secure object: URL: /api/admin/users; Attributes: [hasRole('ROLE_ADMIN')]

Nếu log cho thấy người dùng có [ADMIN] nhưng interceptor yêu cầu [ROLE_ADMIN], bạn đã tìm thấy sự không khớp.

Danh sách kiểm tra phòng ngừa nhanh

- Bật `DEBUG` logging trong quá trình phát triển cục bộ để có thông tin rõ ràng ngay lập tức.
- Chuẩn hóa sớm: sử dụng `hasRole` (có tiền tố) HOẶC `hasAuthority` (nguyên bản) trên toàn bộ dự án.
- Khi sử dụng JWT, hãy kiểm tra xem `JwtAuthenticationConverter` của bạn có đang map các claim sang đúng định dạng authority hay không.
- Xác minh rằng một `AccessDeniedHandler` tùy chỉnh không vô tình che giấu các lỗi 403 thành 401 hoặc 500.

Related Error Notes