Spring Security 6 & JWT Authentication: Hướng Dẫn Toàn Diện Spring Boot 3 (2026)

VMas-Dev-AnHuynh

Spring Security 6 & JWT Authentication: Hướng Dẫn Toàn Diện Spring Boot 3 (2026)

Trong bài viết này, chúng ta sẽ cùng nhau xây dựng một cơ chế xác thực JWT stateless, chuẩn OAuth2 Resource Server trong Spring Boot 3 và Spring Security 6 — từ khâu tạo token đến xác thực và phân quyền. Điểm đặc biệt: chúng ta sẽ tận dụng thư viện chính chủ của Spring (spring-boot-starter-oauth2-resource-server) để giải mã JWT thay vì tự viết JwtFilter thủ công — giúp mã nguồn gọn nhẹ hơn đến 70% và tuân thủ chuẩn RFC.

Sơ đồ luồng xác thực JWT với Spring Security 6 và OAuth2 Resource Server

1. Những Thay Đổi Lớn Trong Spring Security 6 Bạn Cần Biết

Nếu bạn từng làm việc với Spring Security 5, cấu hình thường trông như thế này:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .antMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .httpBasic();
    }
}

Spring Security 6 đã khai tử hoàn toàn cách tiếp cận này. Dưới đây là bảng tóm tắt những thay đổi cốt lõi:

Spring Security 5 Spring Security 6 Ghi chú
extends WebSecurityConfigurerAdapter SecurityFilterChain Bean Không còn kế thừa, dùng Bean thay thế
.and() Lambda DSL Mỗi configurer nhận lambda
antMatchers(), mvcMatchers() requestMatchers() Hợp nhất thành một method duy nhất
@EnableGlobalMethodSecurity @EnableMethodSecurity Bật sẵn @PreAuthorize mặc định
authorizeRequests() authorizeHttpRequests() Tên method mới phản ánh đúng chức năng

1.1 Kế thừa → Bean

Không còn extends WebSecurityConfigurerAdapter nữa. Thay vào đó, bạn định nghĩa một Bean SecurityFilterChain nhận HttpSecurity làm tham số:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Cấu hình tại đây
    return http.build();
}

1.2 .and() → Lambda DSL

Cách viết chuỗi với .and() đã được thay thế bằng Lambda DSL — mỗi configurer nhận một lambda, giúp code gọn và dễ đọc hơn:

// Cũ (Spring Security 5)
http.authorizeRequests()
    .antMatchers("/api/**").authenticated()
    .and()
    .httpBasic();

// Mới (Spring Security 6) — Lambda DSL
http.authorizeHttpRequests(auth -> auth.requestMatchers("/api/**").authenticated())
    .httpBasic(Customizer.withDefaults());

1.3 antMatchers()requestMatchers()

antMatchers(), mvcMatchers(), regexMatchers() được hợp nhất thành một method duy nhất: requestMatchers().

1.4 Method Security: @EnableGlobalMethodSecurity@EnableMethodSecurity

Annotation @EnableGlobalMethodSecurity đã bị xóa bỏ trong Spring Security 6 và phải được thay thế bằng @EnableMethodSecurity. Điểm thuận tiện: @EnableMethodSecurity bật @PreAuthorize/@PostAuthorize mặc định mà không cần cấu hình prePostEnabled = true.

// Cũ
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig { ... }

// Mới
@EnableMethodSecurity
public class SecurityConfig { ... }

So sánh cấu hình Spring Security 5 và Spring Security 6 Lambda DSL

2. Tư Duy Mới: Dùng Spring OAuth2 Resource Server Để Giải Mã JWT

Nhiều lập trình viên khi làm JWT authentication thường có thói quen tự viết một Custom Filter để:

  • Trích xuất token từ Authorization header
  • Giải mã và verify chữ ký
  • Set Authentication vào SecurityContext

Đó là cách làm của thời Spring Security 5.

Với Spring Security 6 và sự có mặt của spring-boot-starter-oauth2-resource-server, bạn không cần tự viết filter nữa. Spring đã cung cấp sẵn:

Thành phần Vai trò
BearerTokenAuthenticationFilter Tự động trích xuất token từ Header
JwtDecoder Giải mã và xác thực chữ ký JWT
JwtAuthenticationProvider Xây dựng Authentication object
JwtAuthenticationConverter Chuyển đổi JWT thành Authentication với authorities

2.1 Tại sao nên dùng OAuth2 Resource Server?

  1. Chuẩn RFC: Tuân thủ OAuth2 Bearer Token specification (RFC 6750)
  2. Ít code hơn 70%: Không cần viết Filter, không cần xử lý parse header thủ công
  3. Tích hợp sẵn với JWK: Hỗ trợ lấy public key từ JWK Set URI của Authorization Server
  4. Bảo trì tốt hơn: Code ít hơn → ít lỗi hơn → dễ bảo trì hơn
  5. Tích hợp với Spring Security ACL: Dễ dàng mở rộng với các tính năng bảo mật nâng cao

2.2 Khi nào vẫn cần Custom Filter?

Chỉ khi bạn có yêu cầu đặc biệt như:

  • Token được gửi trong Cookie thay vì Header
  • Cần log mọi request chứa token (audit logging)
  • Tích hợp với hệ thống authentication không chuẩn OAuth2
  • Cần xử lý token ở định dạng đặc biệt (ví dụ: token được encrypt)

Còn trong 90% trường hợp, OAuth2 Resource Server là lựa chọn tối ưu.

3. Thực Chiến: Xây Dựng Hệ Thống JWT Authentication Hoàn Chỉnh

Phần này sẽ hướng dẫn bạn xây dựng toàn bộ hệ thống xác thực JWT với Spring Security 6, sử dụng thuật toán bất đối xứng RS256 (Public/Private Key) — bao gồm cả tạo token và xác thực token.

3.1 Cấu hình Dependencies

pom.xml:


<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- OAuth2 Resource Server - Quan trọng để giải mã JWT -->
<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<!-- JJWT - Thư viện tạo và verify JWT -->
<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt-api</artifactId>

<version>0.12.6</version>
</dependency>
<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt-impl</artifactId>

<version>0.12.6</version>

<scope>runtime</scope>
</dependency>
<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt-jackson</artifactId>

<version>0.12.6</version>

<scope>runtime</scope>
</dependency>

⚠️ Lưu ý: JJWT 0.12.x có API hoàn toàn khác so với 0.9.x. Đảm bảo dùng đúng version.

3.2 Tạo RSA Public/Private Key Pair cho thuật toán RS256

💡 Best Practice: Nên mã hóa JWT bằng thuật toán bất đối xứng RS256 thay vì HS256 vì Resource Server chỉ cần giữ public key — không bao giờ chạm vào private key.

Bước 1: Tạo private key (PKCS#8 format)

openssl genrsa -out private.pem 2048
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.pem -out private-key.pem

Bước 2: Tạo public key từ private key

openssl rsa -pubout -in private.pem -out public.pem

Bước 3: Đặt file public.pem vào src/main/resources/ của Resource Server.

Bước 4: Cấu hình application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:public.pem

jwt:
  private-key: classpath:private-key.pem  # Dùng để sign token
  expiration: 3600000  # 1 hour

💡 Nếu bạn dùng Authorization Server riêng (ví dụ Keycloak), có thể cấu hình issuer-uri hoặc jwk-set-uri thay vì public-key-location:

spring.security.oauth2.resourceserver.jwt.issuer-uri: https://your-auth-server.com

3.3 Cấu hình UserDetailsService và PasswordEncoder

Trước khi tạo token, chúng ta cần cấu hình cơ chế lưu trữ và xác thực user. Dưới đây là ví dụ dùng InMemoryUserDetailsManager để test nhanh:

package com.example.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class UserDetailsConfig {

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        User.UserBuilder users = User.builder();

        // Tạo user với role ADMIN
        var admin = users
                .username("admin")
                .password(passwordEncoder.encode("admin123"))
                .roles("ADMIN")
                .build();

        // Tạo user với role USER
        var user = users
                .username("user")
                .password(passwordEncoder.encode("user123"))
                .roles("USER")
                .build();

        return new InMemoryUserDetailsManager(admin, user);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

💡 Trong môi trường production, bạn nên thay InMemoryUserDetailsManager bằng implementation lưu user trong database.

3.4 Tạo JWT Token (Login Endpoint)

Đây là phần tạo token mà nhiều bài viết bỏ qua. Chúng ta sẽ dùng JJWT library để tạo JWT với thuật toán RS256.

JwtService.java:

package com.example.security.service;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class JwtService {

    @Value("${jwt.expiration:3600000}")
    private long expiration;

    private final PrivateKey privateKey;

    public JwtService(@Value("${jwt.private-key}") Resource privateKeyResource) 
            throws IOException, NoSuchAlgorithmException {
        this.privateKey = loadPrivateKey(privateKeyResource);
    }

    private PrivateKey loadPrivateKey(Resource resource) throws IOException, NoSuchAlgorithmException {
        // Dùng InputStream để tránh lỗi khi chạy từ JAR
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(resource.getInputStream()))) {
            String key = reader.lines().collect(Collectors.joining("\n"));
            String privateKeyContent = key
                    .replace("-----BEGIN PRIVATE KEY-----", "")
                    .replace("-----END PRIVATE KEY-----", "")
                    .replaceAll("\\s", "");

            byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            return keyFactory.generatePrivate(keySpec);
        }
    }

    public String generateToken(String username, List
<String> roles) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .subject(username)
                .claim("roles", roles)
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(privateKey, SignatureAlgorithm.RS256)
                .compact();
    }
}

AuthController.java — Login Endpoint:

package com.example.security.controller;

import com.example.security.dto.LoginRequest;
import com.example.security.dto.LoginResponse;
import com.example.security.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
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;

import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    @PostMapping("/login")
    public LoginResponse login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

        List
<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        String token = jwtService.generateToken(request.getUsername(), roles);
        return new LoginResponse(token, "Bearer");
    }
}

LoginRequest.javaLoginResponse.java:

// LoginRequest.java
package com.example.security.dto;

import lombok.Data;

@Data
public class LoginRequest {
    private String username;
    private String password;
}

// LoginResponse.java
package com.example.security.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class LoginResponse {
    private String accessToken;
    private String tokenType;
}

3.5 Cấu hình AuthenticationManager Bean

Để AuthController có thể inject AuthenticationManager, chúng ta cần định nghĩa Bean này trong SecurityConfig:

package com.example.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.List;

@Configuration
public class AuthenticationManagerConfig {

    @Bean
    public AuthenticationManager authenticationManager(
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {

        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder);

        return new ProviderManager(List.of(authProvider));
    }
}

3.6 Cấu hình SecurityFilterChain Bean với jwt() decoder

Đây là phần quan trọng nhất — cấu hình SecurityFilterChain với Lambda DSL và kích hoạt OAuth2 Resource Server JWT.

package com.example.security.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationEntryPoint;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // Thay thế cho @EnableGlobalMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final AuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final AccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 1. Tắt CSRF vì API stateless
            .csrf(csrf -> csrf.disable())

            // 2. Cấu hình CORS (nếu cần)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // 3. Stateless session (không tạo session)
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // 4. Exception handling cho 401/403
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
            )

            // 5. Phân quyền endpoint
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )

            // 6. Kích hoạt OAuth2 Resource Server với JWT
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://your-domain.com"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

        // Nếu JWT chứa roles trong claim "roles"
        // và giá trị là ["ADMIN", "USER"] (không có prefix ROLE_)
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);

        // (Tùy chọn) Đặt claim làm principal name (mặc định là "sub")
        jwtAuthenticationConverter.setPrincipalClaimName("sub");

        return jwtAuthenticationConverter;
    }
}

Giải thích code:

  • csrf(csrf -> csrf.disable()): Stateless API không cần CSRF protection
  • sessionManagement(...STATELESS): Không tạo session, mỗi request đều độc lập
  • exceptionHandling(...): Custom xử lý lỗi 401 và 403 (định nghĩa ở Mục 4)
  • .oauth2ResourceServer(oauth2 -> oauth2.jwt(...)): Kích hoạt cơ chế xác thực JWT của Spring
  • jwtAuthenticationConverter(): Custom converter để map roles từ JWT claims

Kiến trúc Security Filter Chain trong Spring Security 6 với OAuth2 Resource Server

4. Xử Lý Lỗi Exception Khi Token Bị Hết Hạn Hoặc Khai Báo Sai

Mặc định, khi JWT hết hạn hoặc không hợp lệ, Spring Security trả về 403 Forbidden thay vì 401 Unauthorized. Điều này không chuẩn với REST API. Chúng ta cần custom AuthenticationEntryPointAccessDeniedHandler.

4.1 Custom AuthenticationEntryPoint (xử lý 401)

package com.example.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
        body.put("error", "Unauthorized");
        body.put("message", authException.getMessage());
        body.put("timestamp", Instant.now().toString());
        body.put("path", request.getRequestURI());

        new ObjectMapper().writeValue(response.getOutputStream(), body);
    }
}

4.2 Custom AccessDeniedHandler (xử lý 403)

package com.example.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("status", HttpServletResponse.SC_FORBIDDEN);
        body.put("error", "Forbidden");
        body.put("message", accessDeniedException.getMessage());
        body.put("timestamp", Instant.now().toString());
        body.put("path", request.getRequestURI());

        new ObjectMapper().writeValue(response.getOutputStream(), body);
    }
}

4.3 Đăng ký handlers trong SecurityFilterChain

// Trong SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final AuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final AccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)  // 401
                .accessDeniedHandler(jwtAccessDeniedHandler)           // 403
            )
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            );

        return http.build();
    }
}

💡 Lưu ý: Trong Spring Security 6.2+, http.exceptionHandling() vẫn được hỗ trợ với Lambda DSL.

5. Lỗi Thường Gặp Và Cách Khắc Phục

🔴 Lỗi 1: 403 Forbidden dù đã gửi đúng Bearer Token

Nguyên nhân: Spring Security mặc định tìm kiếm prefix ROLE_ trong Authority nhưng JWT Payload lại thiếu prefix này.

Cách fix: Cấu hình JwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"):

JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix("ROLE_");  // Thêm prefix ROLE_
converter.setAuthoritiesClaimName("roles");  // Claim chứa roles

🔴 Lỗi 2: @PreAuthorize("hasRole('ADMIN')") không hoạt động

Nguyên nhân: Chưa bật @EnableMethodSecurity hoặc đang dùng @EnableGlobalMethodSecurity cũ.

Cách fix:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // ← BẮT BUỘC cho @PreAuthorize hoạt động
public class SecurityConfig { ... }

🔴 Lỗi 3: JWT không được giải mã dù đã cấu hình public key

Nguyên nhân: Có thể bạn chưa thêm dependency spring-boot-starter-oauth2-resource-server.

Cách fix: Thêm vào pom.xml:


<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

🔴 Lỗi 4: antMatchers() không còn được dùng

Nguyên nhân: Spring Security 6 đã loại bỏ antMatchers().

Cách fix: Thay bằng requestMatchers():

// Cũ
http.authorizeRequests().antMatchers("/api/**").authenticated();

// Mới
http.authorizeHttpRequests(auth -> auth.requestMatchers("/api/**").authenticated());

🔴 Lỗi 5: JwtAuthenticationConverter không tìm thấy

Nguyên nhân: Thiếu import hoặc dependency.

Cách fix: Đảm bảo import đúng:

import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

🔴 Lỗi 6: AuthenticationManager Bean không tìm thấy

Nguyên nhân: Chưa định nghĩa Bean AuthenticationManager trong SecurityConfig.

Cách fix: Thêm Bean như ở Mục 3.5:

@Bean
public AuthenticationManager authenticationManager(
        UserDetailsService userDetailsService,
        PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    authProvider.setUserDetailsService(userDetailsService);
    authProvider.setPasswordEncoder(passwordEncoder);
    return new ProviderManager(List.of(authProvider));
}

6. Best Practices Cho Hệ Thống JWT Stateless

✅ 1. Luôn disable CSRF cho REST API

http.csrf(csrf -> csrf.disable());

Vì JWT stateless không dùng cookie session, CSRF không phải là mối đe dọa.

✅ 2. Dùng RS256 thay vì HS256

  • HS256 (đối xứng): Cùng một secret key để sign và verify → rủi ro nếu lộ key.
  • RS256 (bất đối xứng): Private key sign, public key verify → Resource Server chỉ cần public key.

✅ 3. Thiết lập thời gian hết hạn token hợp lý

  • Access Token: 15 phút – 1 giờ
  • Refresh Token: 7 – 30 ngày (nếu có)

✅ 4. Luôn trả về JSON cho lỗi authentication/authorization

Không để Spring trả về HTML error page mặc định. Dùng custom AuthenticationEntryPointAccessDeniedHandler.

✅ 5. Log đầy đủ nhưng không leak thông tin nhạy cảm

logging:
  level:
    org.springframework.security: DEBUG  # Chỉ ở môi trường dev

✅ 6. Sử dụng @PreAuthorize thay vì cấu hình trong SecurityFilterChain

Phân quyền ở method level giúp code rõ ràng và linh hoạt hơn:

@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")  // Class-level
public class AdminController {

    @PreAuthorize("hasAuthority('reports:read')")  // Method-level
    @GetMapping("/reports")
    public List
<Report> getReports() { ... }

    @PreAuthorize("hasAuthority('orders:read')")
    @GetMapping("/orders")
    public List
<Order> getOrders() { ... }
}

✅ 7. Lưu token một cách an toàn ở client

  • Web: Lưu trong HttpOnly Cookie (chống XSS) + Secure flag
  • Mobile: Lưu trong Secure Storage của OS
  • Không bao giờ lưu token trong localStorage (dễ bị XSS)

✅ 8. Validate đầy đủ các claim

Nên validate:

  • iss (issuer) — đúng với Authorization Server
  • aud (audience) — kiểm tra ứng dụng của bạn
  • exp (expiration) — kiểm tra token còn hạn
  • iat (issued at) — token không được tạo trong tương lai

7. So Sánh Kiến Trúc: Security Filter Chain (Spring) vs Guards (NestJS)

Nếu bạn đã từng làm việc với NestJS, bạn sẽ thấy sự tương đồng thú vị. Điều này giúp các developer đa nền tảng dễ dàng tiếp cận:

Spring Security NestJS Mô tả
SecurityFilterChain Guard Chain of Responsibility cho bảo mật
AuthenticationFilter AuthGuard Xác thực người dùng
AuthorizationFilter RolesGuard Phân quyền truy cập
@PreAuthorize @Roles() / @UseGuards() Phân quyền ở method/controller
AuthenticationEntryPoint Exception filter cho 401 Xử lý lỗi chưa xác thực
AccessDeniedHandler Exception filter cho 403 Xử lý lỗi không đủ quyền
SecurityContext Request object Lưu thông tin authentication

Cả hai framework đều áp dụng Chain of Responsibility pattern cho bảo mật. Điểm khác biệt: Spring Security cung cấp sẵn OAuth2 Resource Server tích hợp, trong khi NestJS thường dùng thư viện bên thứ ba như @nestjs/passport.

8. Câu Hỏi Thường Gặp (FAQ)

❓ Tại sao không nên dùng WebSecurityConfigurerAdapter nữa?

WebSecurityConfigurerAdapter bị deprecated từ Spring Security 5.7 và bị xóa hoàn toàn trong Spring Security 6. Cách tiếp cận mới dùng SecurityFilterChain Bean giúp cấu hình linh hoạt hơn, hỗ trợ nhiều security chain khác nhau trong cùng một ứng dụng (ví dụ: cấu hình khác nhau cho API và Admin).

❓ Làm thế nào để xử lý Refresh Token an toàn với Spring Security 6?

Có hai cách tiếp cận phổ biến:

  1. Refresh Token trong Cookie HttpOnly: Lưu refresh token trong cookie secure, http-only để chống XSS
  2. Refresh Token endpoint: Tạo endpoint /api/auth/refresh nhận refresh token (gửi qua body hoặc header) và trả về access token mới

Với Spring Security 6, bạn có thể tự implement AuthenticationProvider hoặc dùng OAuth2 Authorization Server (Spring Security 6 cung cấp module spring-security-oauth2-authorization-server riêng).

❓ Có cần tự viết JwtFilter không?

Không, nếu bạn dùng spring-boot-starter-oauth2-resource-server. Spring đã cung cấp BearerTokenAuthenticationFilter làm việc này. Chỉ tự viết filter khi có yêu cầu đặc biệt (token trong cookie, custom header, etc.).

❓ Làm sao để debug JWT authentication?

Thêm cấu hình log:

logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: DEBUG

Và kiểm tra token bằng công cụ như jwt.io để xem payload.

❓ Sự khác biệt giữa opaque token và JWT là gì?

  • JWT (JSON Web Token): Self-contained, chứa đầy đủ thông tin (claims) trong token, không cần gọi đến Authorization Server để verify.
  • Opaque Token: Token không chứa thông tin, cần gọi đến Authorization Server (Introspection Endpoint) để lấy thông tin user và permissions.

Khi nào dùng JWT? Khi bạn muốn giảm số lượng request đến Authorization Server, tăng performance.

Khi nào dùng Opaque Token? Khi bạn muốn thu hồi token ngay lập tức (JWT không thể thu hồi cho đến khi hết hạn).

❓ Làm thế nào để kiểm tra API với Postman?

  1. Gửi request POST đến /api/auth/login với body JSON:
    {
     "username": "admin",
     "password": "admin123"
    }
  2. Copy token từ response
  3. Gửi request GET đến API cần bảo vệ với Header:
    Authorization: Bearer <your-token>

9. Kết Luận

Spring Security 6 mang đến một làn gió mới với Lambda DSL, SecurityFilterChain, và sự tích hợp mạnh mẽ với OAuth2 Resource Server. Việc nâng cấp có thể gây bỡ ngỡ ban đầu, nhưng khi đã nắm được tư duy mới, bạn sẽ thấy code sạch hơn, an toàn hơndễ bảo trì hơn.

Tóm tắt những điểm chính:

  • ✅ Khai tử WebSecurityConfigurerAdapter → dùng SecurityFilterChain Bean
  • .and() → Lambda DSL
  • antMatchers()requestMatchers()
  • @EnableGlobalMethodSecurity@EnableMethodSecurity
  • ✅ Tận dụng spring-boot-starter-oauth2-resource-server thay vì tự viết filter
  • ✅ Tạo token với JJWT và thuật toán RS256
  • ✅ Dùng RS256 và disable CSRF cho stateless API
  • ✅ Custom AuthenticationEntryPointAccessDeniedHandler để trả về JSON chuẩn
  • ✅ Cấu hình AuthenticationManager với DaoAuthenticationProviderPasswordEncoder
  • ✅ Xử lý Refresh Token với Cookie HttpOnly hoặc endpoint riêng
Chia sẻ bài viết này
By VMas-Dev-AnHuynh Software Engineer
Follow:
SOFTWARE ENGINEER / FULL-STACK DEVELOPER