SpringSecurity

Published 2026-08-03 10:03 Updated 2026-08-03 10:03 4933 words 25 min read ... Page views

This article introduces the core concepts and implementation mechanisms of the Spring Security 6 enterprise-level security framework, focusing on the basic process of identity authentication and access authorization, core components (such as SecurityFilterChain, AuthenticationManager, UserDetailsService, etc.), and JWT authentication practices in scenarios where front-end separation is possible. The article emphasizes the correctness of security configuration, the standardization of password coding, and the necessity of CSRF and XSS protection, and points out that appropriate authentication storage methods and security policies should be selected based on threat models in the production environment.

Spring Security 6 Enterprise Security Framework

Spring Security Fundamentals

What is Spring Security

Spring Security is a security framework in the Spring ecosystem. It is mainly used to handle identity authentication, access authorization and common security protection in Servlet applications and responsive applications.

Compared with Apache Shiro, Spring Security integrates more closely with components such as Spring Boot, Spring MVC, and OAuth 2.0, and has richer extension points. Framework has many capabilities, so you should first master core concepts such as filter chains, authentication objects, and authorization rules when learning.

authentication and authorization

Enterprise applications often need to address two core issues:

  • Authentication: Confirm who the current visitor is and whether his identity is legal.
  • Authorization: Confirm whether an authenticated user has the right to access a resource or perform a certain operation.

Take the online education platform as an example:

User TypeAllowed operations
StudentView Course
Teacherreleases course
AdministratorDelete user

Certification solves "who you are" and authorization solves "what you can do".

Key capabilities of Spring Security

Common capabilities of Spring Security include:

  1. Identity authentication mechanisms such as username and password authentication, certificate authentication, and one-time token authentication.
  2. Access authorization at the URL level and method level.
  3. Password encoding and verification.
  4. Session management, fixed session attack protection and concurrent session control.
  5. CSRF protection, security response and other Web security capabilities.
  6. Integration of OAuth 2.0 clients, resource servers and OpenID Connect.

XSS mainly needs to be protected through output encoding, content security policies and front-end security development specifications. Spring Security can configure some security response headers, but it cannot replace business-level XSS protection.

CSRF

CSRF is cross-site request forgery. An attacker induces a user who has logged in to a legitimate site to visit a malicious page, and the browser may automatically carry the cookies of the legitimate site, making the legitimate site mistakenly believe that the request was initiated by the user himself.

XSS

XSS is a cross-site scripting attack. Attackers try to inject malicious scripts into web pages. When other users visit the page, the browser executes the script, which may cause information theft, identity fraud, or page tampering.

Session mode and Token mode

Spring Security supports both stateful and stateless authentication. The separation of front and back ends does not mean that only Tokens can be used, but in distributed APIs, stateless solutions using Bear Token are more common.

Comparison ItemsSession Stateful CertificationToken Stateful Certification
status locationThe server saves the session, and the client usually saves the session CookieThe server usually does not save the login session, and the client carries the access token
cluster deploymentrequires session replication, shared storage, or sticky sessionsEach node can independently verify the token
browser automatically carriescookies, usually the browser automatically carriesAuthorization request header, usually the client code actively adds
Cancellation and revocationThe server deletes the session immediately.requires mechanisms such as short validity period, blacklist or token version
typical scenariosserver-side rendering website, homologous Web applicationfront-end separation API, microservice resource server

Depositing Tokens into localStorage will increase the risk of Tokens being stolen by XSS. Production systems should choose memory, protected cookies, or other storage options based on the threat model. If you use cookies automatically carried by your browser, you still need to evaluate and configure CSRF protection.

core architecture

image-001
image-001

Spring Security's Servlet security capabilities are built on a filter chain. Before the request enters the business controller, it will first go through the SecurityFilterChain selected by Spring Security.

Common core components are as follows:

ComponentRole
SecurityFilterChaindeclares the security filter that needs to be executed for the current request
FilterChainProxyMatch and execute the corresponding SecurityFilterChain
Authenticationrepresents a certification request or currently certified subject
AuthenticationManagerDefinition Certification Entrance
AuthenticationProviderImplement a specific certification method
UserDetailsServiceLoad user information by user name
PasswordEncoderCode the password and verify the password
SecurityContextsaves the Authentication
SecurityContextHolderprovides unified access to SecurityContext
interview question: What is the main role of Spring Security?

Spring Security mainly solves identity authentication and access authorization issues in applications, and provides security capabilities such as cryptographic encoding, session management, CSRF protection, security response headers, and OAuth 2.0 integration.

Spring Boot 3 integrates Spring Security

environmental requirements

This course example uses the following environment:

Spring Boot 3.3+
Java 17+
Spring Security 6
Maven
Vue 3

Introduce dependence

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

Write the first interface

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/hello")
    public String hello() {
        return "hello security";
    }
}

Visit http://localhost:8080/user/hello after starting the project. When there is no custom security configuration, Spring Boot enables the default security configuration for the Web application and displays the login page.

image-002
image-002

default user

When there are no custom certification configurations such as UserDetailsService and AuthenticationProvider in the project, Spring Boot usually creates default users:

username: user
password: 启动时随机生成

The random password is output to the console.

image-003
image-003

Customize default username and password

spring:
  security:
    user:
      name: admin
      password: 123456

This configuration is suitable for entry demonstrations and not suitable for production environments. The production system should load users from a database, LDAP, or external identity service, and use PasswordEncoder to save a password summary.

Formation process of default login page

In the default configuration, the startup and access process can be summarized as:

Spring Boot 启动
→ 加载 Spring Security 自动配置
→ 创建安全过滤器链
→ 请求被过滤器链拦截
→ 发现请求尚未认证
→ 进入登录流程或返回认证响应
Interview Question: Why does Spring Security not just use Controllers to intercept requests?

Spring Security's Servlet support is built on top of the Filter mechanism. The filter is located in front of DispatcherServlet and the controller and can complete authentication, authorization and security context management before requests enter the business layer.

A typical sequence can be simplified to:

Servlet Filter → DispatcherServlet → HandlerInterceptor → Controller

Core principles of Spring Security

learning goals

After completing this chapter, you should be able to:

  1. Understand the overall request process of Spring Security.
  2. Master the working mechanism of SecurityFilterChain.
  3. Understand the two main uses of Authentication.
  4. Understand the relationship between AuthenticationManager and AuthenticationProvider.
  5. Understand the responsibilities of SecurityContext and SecurityContextHolder.
  6. Analyze the main execution steps of username and password authentication.

Filter-based security processing

In traditional Spring MVC applications, the request process can be simplified to:

客户端请求
→ Servlet Filter
→ DispatcherServlet
→ HandlerInterceptor
→ Controller
→ Service
→ DAO
→ Database

Spring Security selects filters as security entrances to servlets so that authentication and authorization can be completed before business code is executed.

For example, when a user requests GET /api/user/delete/1, the security filter chain first determines whether the user has been authenticated and has delete rights. When verification fails, the request does not enter the target controller.

SecurityFilterChain

SecurityFilterChain represents a set of filters used to handle security tasks. An application can declare multiple chains of security filters, each chain responsible for a different request scope.

image-004
image-004

DelegateFilterProxy and FilterChainProxy

Spring Security's Servlet request forwarding relationship can be summarized as:

DelegatingFilterProxy
→ FilterChainProxy
→ 匹配到的 SecurityFilterChain
→ 链内的 Security Filter
image-005
image-005
  1. DelegatingFilterProxy is registered in the Servlet container and is responsible for delegating filtering work to the Filter Bean managed by the Spring container.
  2. FilterChainProxy is the core entry of Spring Security and will match the appropriate SecurityFilterChain based on the current request.
  3. The selected SecurityFilterChain executes the safety filters in sequence.

The specific composition of the filter depends on the configuration. Common filters include:

  • SecurityContextHolderFilter: Load and manage security contexts.
  • CsrfFilter: Verify CSRF Token when enabling CSRF.
  • UsernamePasswordAuthenticationFilter: Enable processing of user name and password login requests when logging in form.
  • BearerTokenAuthenticationFilter: Processing Bear Token when configuring the OAuth 2.0 resource server.
  • AnonymousAuthenticationFilter: Provide anonymous Authentication for anonymous requests.
  • ExceptionTranslationFilter: Convert authentication and authorization exceptions into corresponding processing procedures.
  • AuthorizationFilter: Perform request-level authorization judgment.

FilterSecurityInterceptor will appear in some old architecture diagrams. In common configurations of Spring Security 6, requests for authorization are usually completed by AuthorizationFilter. The actual filter list should be based on the project startup log or debugging output.

Authentication model

image-006
image-006

Role of Authentication

Authentication has two main uses:

  1. Submit it to AuthenticationManager as a certificate to be certified.
  2. Represents the entity that has been currently certified and saves it in SecurityContext.

Common methods in the interface are as follows:

public interface Authentication extends Principal, Serializable {
    Object getPrincipal();

    Object getCredentials();

    Collection<? extends GrantedAuthority> getAuthorities();

    boolean isAuthenticated();
}
MethodAction
getPrincipal()Returns to the main body. After successful user name and password authentication, it is usually UserDetails
getCredentials()Returns the certificate, usually a password; it may be cleared after successful authentication
getAuthorities()Returns the set of roles, permissions, or scopes
isAuthenticated()indicates whether the current object has been certified

Status before and after certification

UsernamePasswordAuthenticationToken created before logging in usually only contains a username and password and is in an unauthenticated state:

UsernamePasswordAuthenticationToken
├── principal: admin
├── credentials: 123456
├── authorities: []
└── authenticated: false

The new object returned after successful authentication usually contains user details and permissions, and is in an authenticated state:

UsernamePasswordAuthenticationToken
├── principal: UserDetails
├── credentials: null
├── authorities: [ROLE_ADMIN, permission:query]
└── authenticated: true

Do not directly mark any unverified object as authenticated in an unsafe manner.

AuthenticationManager and AuthenticationProvider

AuthenticationManager defines unified certification entry:

Authentication authenticate(Authentication authentication)
        throws AuthenticationException;

ProviderManager is the most common implementation of AuthenticationManager. It will query multiple AuthenticationProvider in turn, and the Provider that supports the current Authentication type performs certification.

A typical username and password authentication process is as follows:

Authentication
→ AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService
→ PasswordEncoder

Different authentication methods can use different providers. For example:

  • DaoAuthenticationProvider handles database username and password authentication.
  • JwtAuthenticationProvider in the OAuth 2.0 resource server handles JWT Bear Tokens.
  • Custom AuthenticationProvider can process business vouchers such as SMS Captcha.

UserDetailsService

Spring Security does not know where users are saving it, so it abstracts the user query process through UserDetailsService.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException;
}

User information can come from MySQL, Redis, LDAP, or other user centers.

Here is a simplified example:

@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userMapper.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("账号不存在");
        }

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .roles("USER")
                .build();
    }
}

SecurityContext and SecurityContextHolder

SecurityContext saves the current authentication object, and SecurityContextHolder provides access to the current security context.

Authentication authentication = SecurityContextHolder
        .getContext()
        .getAuthentication();

In Servlet applications, SecurityContextHolder uses the ThreadLocal policy by default, so that code in the same request thread can read the current user.

ThreadLocal is just a default policy and does not mean that a request always uses the same thread in all situations. Asynchronous tasks, thread pools, and responsive programming require the use of appropriate context propagation mechanisms.

interview question: Why does SecurityContext use ThreadLocal by default?

Default Servlet requests are usually processed in threads. Using ThreadLocal allows code in the same thread to directly obtain current authentication information, avoid passing parameters layer by layer, and isolate the security contexts of different requesting threads. Spring Security cleans the context after the request ends to avoid identity disclosure caused by thread reuse.

User name password login process

When enabling form login, a typical process is as follows:

POST /login
→ UsernamePasswordAuthenticationFilter
→ 创建未认证 UsernamePasswordAuthenticationToken
→ AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService 查询用户
→ PasswordEncoder 校验密码
→ 返回已认证 Authentication
→ 保存到 SecurityContext
→ 执行认证成功处理

Later in this course, custom LoginController is used to call AuthenticationManager. In this case, the login request will not go through UsernamePasswordAuthenticationFilter, and the controller is responsible for generating JWT; subsequent requests will be parsed by a custom JWT filter and the SecurityContext of the current request will be established.

this chapter summarizes

ComponentRole
SecurityFilterChaindefines the security filter that requests need to be executed
FilterChainProxyMatches and implements safety filter chain
Authenticationrepresents a certification request or current certification subject
AuthenticationManagerprovides unified certification entrance
ProviderManagerDispatch multiple AuthenticationProvider
AuthenticationProviderimplements specific certification logic
UserDetailsServiceLoad user information
PasswordEncoderCode and verify password
SecurityContextSave current certification object
SecurityContextHolderAccess the current security context
Interview Question: What is the difference between Authentication and UserDetails?

UserDetails describes account information loaded from user data sources, such as user name, password summary, account status and permissions.

Authentication can represent either the certificate to be authenticated or the current entity that has passed the authentication. After successful certification, its principal is usually an example of UserDetails.

Front-end separation authentication

image-007
image-007

This chapter adopts the following scheme:

用户名密码登录
→ AuthenticationManager 校验
→ 服务端签发 JWT
→ 客户端使用 Authorization 请求头携带 JWT
→ JWT 过滤器验签并建立 SecurityContext
→ Spring Security 执行访问授权

database table structure

The following example maintains user, role, permissions, and menu relationships. Add a stable English role_key to the role table, which is used to generate ROLE_ADMIN, ROLE_USER and other permission identifiers.

DROP TABLE IF EXISTS `t_menu_role`;
DROP TABLE IF EXISTS `t_role_permission`;
DROP TABLE IF EXISTS `t_menu`;
DROP TABLE IF EXISTS `t_permission`;
DROP TABLE IF EXISTS `t_user`;
DROP TABLE IF EXISTS `t_role`;

CREATE TABLE `t_menu` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
    `name` VARCHAR(255) DEFAULT NULL COMMENT '菜单名称',
    `icon` VARCHAR(255) DEFAULT NULL COMMENT '菜单图标',
    `url` VARCHAR(255) DEFAULT NULL COMMENT '菜单 URL',
    `pid` BIGINT DEFAULT NULL COMMENT '父菜单 ID',
    `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
    `level` INT DEFAULT NULL COMMENT '菜单层级',
    `is_link` TINYINT DEFAULT 0 COMMENT '是否可跳转',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_role` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `role_name` VARCHAR(255) NOT NULL,
    `role_key` VARCHAR(64) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_permission` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `permission_name` VARCHAR(255) NOT NULL,
    `permission_key` VARCHAR(255) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_permission_key` (`permission_key`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_user` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(255) NOT NULL,
    `password` VARCHAR(255) NOT NULL,
    `role_id` BIGINT NOT NULL,
    `nickname` VARCHAR(255) DEFAULT NULL,
    `email` VARCHAR(255) DEFAULT NULL,
    `phone` VARCHAR(255) DEFAULT NULL,
    `freeze` TINYINT DEFAULT 0,
    `dept_id` BIGINT DEFAULT NULL,
    `remark` VARCHAR(255) DEFAULT NULL,
    `insert_time` DATETIME DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_username` (`username`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_role_permission` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `role_id` BIGINT NOT NULL,
    `permission_id` BIGINT NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_menu_role` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `menu_id` BIGINT NOT NULL,
    `role_id` BIGINT NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_menu_role` (`menu_id`, `role_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

START TRANSACTION;

INSERT INTO `t_role` (`id`, `role_name`, `role_key`) VALUES
    (1, '系统管理员', 'ADMIN'),
    (2, '普通用户', 'USER');

INSERT INTO `t_permission` (`id`, `permission_name`, `permission_key`) VALUES
    (1, '查询', 'permission:query'),
    (2, '新增', 'permission:insert'),
    (3, '修改', 'permission:update'),
    (4, '删除', 'permission:delete');

INSERT INTO `t_role_permission` (`id`, `role_id`, `permission_id`) VALUES
    (50, 1, 1),
    (51, 1, 2),
    (52, 1, 3),
    (53, 1, 4),
    (61, 2, 1);

INSERT INTO `t_menu` (`id`, `name`, `icon`, `url`, `pid`, `remark`, `level`, `is_link`) VALUES
    (1, '系统设置', 'layui-icon-fire', NULL, -1, '', 1, 0),
    (2, '分类管理', 'layui-icon-name', 'category/list', 1, '', 2, 1),
    (3, '商品管理', 'layui-icon-service', NULL, -1, '', 1, 0),
    (4, '商品列表', 'layui-icon-rate', 'asset-info/list', 3, '', 2, 1);

INSERT INTO `t_menu_role` (`id`, `menu_id`, `role_id`) VALUES
    (1, 1, 1),
    (2, 2, 1),
    (3, 3, 1),
    (4, 4, 1),
    (5, 3, 2),
    (6, 4, 2);

INSERT INTO `t_user`
    (`id`, `username`, `password`, `role_id`, `nickname`, `insert_time`)
VALUES
    (1, 'admin',
     '$2a$10$t9BSP6hInmZm5RJocXVMdOLXzVXh4wgiBaYrM6iUslsrb.5z.eYce',
     1, '超级管理员', '2021-05-26 14:41:06'),
    (2, 'normal',
     '$2a$10$Mv1ruD0gHy9Uq73SbfH80ep1McuJNZiJCjM3BpxAJVr9pt34iwWlS',
     2, '普通用户', NULL);

COMMIT;

Back-end dependence

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.6</version>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

JJWT modules in the same project should use the same version.

Front-end request interceptor

After successful login, the front end saves the access token and carries Bearer Token in the Authorization request header of subsequent requests.

import axios from 'axios'

const service = axios.create({
  baseURL: 'http://localhost:8080',
  timeout: 5000
})

service.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => Promise.reject(error)
)

service.interceptors.response.use(
  response => response.data,
  error => {
    const status = error.response?.status

    if (status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }

    return Promise.reject(error)
  }
)

export default service

The HTTP status code should be handled separately from the service code in the service response body:

  • 401 Unauthorized means that it is not authenticated, the Token is invalid, or the Token has expired.
  • 403 Forbidden said it has been certified but has no access rights.

Security core configuration

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.nio.charset.StandardCharsets;
import java.util.List;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private static final String[] WHITE_LIST = {
            "/login",
            "/doc.html",
            "/webjars/**",
            "/favicon.ico",
            "/druid/**",
            "/public/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint((request, response, exception) -> {
                            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
                            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                            response.getWriter().write(
                                    "{\"code\":401,\"msg\":\"未登录或令牌无效\"}"
                            );
                        })
                        .accessDeniedHandler((request, response, exception) -> {
                            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
                            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                            response.getWriter().write(
                                    "{\"code\":403,\"msg\":\"没有访问权限\"}"
                            );
                        }))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        .requestMatchers(WHITE_LIST).permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(
                        jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class
                );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:5173"));
        configuration.setAllowedMethods(List.of(
                "GET", "POST", "PUT", "DELETE", "OPTIONS"
        ));
        configuration.setAllowedHeaders(List.of(
                HttpHeaders.AUTHORIZATION,
                HttpHeaders.CONTENT_TYPE
        ));
        configuration.setExposedHeaders(List.of(HttpHeaders.AUTHORIZATION));
        configuration.setAllowCredentials(false);

        UrlBasedCorsConfigurationSource source =
                new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

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

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

The Token in this example is only passed through the Authorization request header, and the server does not rely on browser cookies, so CSRF is turned off. If you change to cookies to automatically carry credentials, you should re-evaluate and enable appropriate CSRF protection.

@EnableWebSecurity is used to import Spring Security's Servlet Web Security configuration. The real request rules are defined by the SecurityFilterChain Bean;Spring Boot Starter is responsible for related automatic configuration and basic integration.

CORS allowed sources in the production environment must be configured as real front-end domain names, and no source should be unconditionally released.

Generating Entity and Mapper

You can use MyBatis Plus to reverse engineer User, Role, Permission, Menu and their Mapper. After generation, field types, table association queries and null value processing still need to be checked, and the reverse engineering result cannot be directly regarded as a complete business code.

Implement UserDetails

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

import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;

public class UserItem implements UserDetails {

    private final User user;
    private final List<String> permissions;
    private final List<GrantedAuthority> authorities;

    public UserItem(User user, String roleKey, List<String> permissions) {
        this.user = user;
        this.permissions = List.copyOf(permissions);
        this.authorities = Stream.concat(
                        Stream.of(new SimpleGrantedAuthority("ROLE_" + roleKey)),
                        permissions.stream().map(SimpleGrantedAuthority::new)
                )
                .map(GrantedAuthority.class::cast)
                .toList();
    }

    public User getUser() {
        return user;
    }

    public List<String> getPermissions() {
        return permissions;
    }

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

    @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.getFreeze() == null || user.getFreeze() == 0;
    }

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

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

hasRole('ADMIN') will automatically check ROLE_ADMIN, so the stable role ID in the database should be saved as ADMIN, and the ROLE_ prefix should be added when constructing permissions.

Implement UserDetailsService

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserMapper userMapper;
    private final RoleMapper roleMapper;
    private final PermissionMapper permissionMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userMapper.selectOne(
                new LambdaQueryWrapper<User>()
                        .eq(User::getUsername, username)
                        .last("LIMIT 1")
        );

        if (user == null) {
            throw new UsernameNotFoundException("账号不存在");
        }

        Role role = roleMapper.selectById(user.getRoleId());
        if (role == null) {
            throw new UsernameNotFoundException("账号未配置有效角色");
        }

        List<String> permissions = permissionMapper
                .selectPermissionListByRoleId(user.getRoleId())
                .stream()
                .map(Permission::getPermissionKey)
                .toList();

        return new UserItem(user, role.getRoleKey(), permissions);
    }
}

BCrypt cryptographic encoding

BCryptPasswordEncoder uses an adaptive one-way hash algorithm with random salts. The same plaintext is usually encoded with different results each time, but it can be verified through matches().

@Test
void shouldEncodeAndMatchPassword() {
    PasswordEncoder encoder = new BCryptPasswordEncoder();

    String encodedPassword = encoder.encode("123456");
    boolean matched = encoder.matches("123456", encodedPassword);

    System.out.println(encodedPassword);
    System.out.println(matched);
}

Password digest cannot be "decrypted" back into plaintext. Use matches(rawPassword, encodedPassword) verification when logging in, rather than comparing strings after encoding again.

Unified response object

public record Result<T>(int code, T data, String msg, long count) {

    public static <T> Result<T> ok(T data, String msg) {
        return new Result<>(200, data, msg, 0);
    }

    public static Result<Void> ok(String msg) {
        return new Result<>(200, null, msg, 0);
    }

    public static Result<Void> fail(int code, String msg) {
        return new Result<>(code, null, msg, 0);
    }

    public static Result<Void> fail(String msg) {
        return fail(500, msg);
    }
}

The business response code should maintain clear semantics with the HTTP status code. Actual projects can use ResponseEntity or global exception handler to uniformly set HTTP status.

JWT Certification Filter

The custom login interface is only responsible for issuing tokens. Each subsequent protected request also needs to resolve the Token and put the authentication result into the SecurityContext of the current request.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String BEARER_PREFIX = "Bearer ";

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authorization.substring(BEARER_PREFIX.length()).trim();

        try {
            Claims claims = jwtUtil.parseToken(token);

            if (SecurityContextHolder.getContext().getAuthentication() == null) {
                List<?> values = claims.get("permissions", List.class);
                List<GrantedAuthority> authorities = values == null
                        ? List.of()
                        : values.stream()
                                .filter(String.class::isInstance)
                                .map(String.class::cast)
                                .map(SimpleGrantedAuthority::new)
                                .map(GrantedAuthority.class::cast)
                                .toList();

                UsernamePasswordAuthenticationToken authentication =
                        UsernamePasswordAuthenticationToken.authenticated(
                                claims.getSubject(),
                                null,
                                authorities
                        );

                SecurityContext context =
                        SecurityContextHolder.createEmptyContext();
                context.setAuthentication(authentication);
                SecurityContextHolder.setContext(context);
            }

            filterChain.doFilter(request, response);
        } catch (JwtException | IllegalArgumentException exception) {
            SecurityContextHolder.clearContext();
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(
                    "{\"code\":401,\"msg\":\"令牌无效或已过期\"}"
            );
        }
    }
}

This example uses permissions in JWT directly. The advantage is that there is no need to query the database every time. The disadvantage is that permission changes will not immediately affect the old Token. The production system can use short-term tokens, token version numbers, blacklists, or re-query key permissions for each request.

Customize login interface

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
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.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequiredArgsConstructor
public class LoginController {

    private final AuthenticationManager authenticationManager;
    private final MenuService menuService;
    private final JwtUtil jwtUtil;

    @PostMapping("/login")
    public Result<Map<String, Object>> login(
            @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(),
                        request.password()
                )
        );

        UserItem userItem = (UserItem) authentication.getPrincipal();
        User user = userItem.getUser();

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

        String token = jwtUtil.createToken(
                user.getId(),
                user.getUsername(),
                user.getRoleId(),
                authorities
        );

        List<Menu> menuList = menuService.listByRoleId(user.getRoleId());

        Map<String, Object> data = new HashMap<>();
        data.put("token", token);
        data.put("user", user);
        data.put("menuList", menuList);
        data.put("authorities", authorities);

        return Result.ok(data, "登录成功");
    }

    public record LoginRequest(String username, String password) {
    }
}

It is recommended to hand over authentication exceptions to the global exception handler for unified conversion:

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<Result<Void>> handleBadCredentials() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Result.fail(401, "用户名或密码错误"));
    }

    @ExceptionHandler(LockedException.class)
    public ResponseEntity<Result<Void>> handleLocked() {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(Result.fail(403, "账号已被冻结"));
    }
}

Do not put sensitive fields such as passwords and password summaries directly into the login response. You can create a dedicated user response object that returns only the fields actually needed by the front end.

interface test

After successful login, the Token, basic user information, menu and permission set are returned in the response. Subsequent requests should carry:

Authorization: Bearer <token>
image-008
image-008

Access control actual combat

Activate method-level authorization

Spring Security 6 uses @EnableMethodSecurity to enable method-level authorization.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@SpringBootApplication
@EnableMethodSecurity
public class SecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }
}

spring-boot-starter-security will not enable method-level authorization by default, so @EnableMethodSecurity needs to be explicitly added.

Using @PreAuthorize

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PermissionController {

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public String admin() {
        return "管理员接口访问成功";
    }

    @PreAuthorize("hasAuthority('permission:query')")
    @GetMapping("/user/list")
    public String userList() {
        return "用户列表接口访问成功";
    }

    @PreAuthorize("hasAnyRole('ADMIN', 'USER')")
    @GetMapping("/common")
    public String common() {
        return "通用接口访问成功";
    }
}

Common expressions are as follows:

ExpressionMeaning
DoeshasRole('ADMIN')
DoeshasAnyRole('ADMIN', 'USER')
hasAuthority('permission:query') Doeshave the specified authority
DoeshasAnyAuthority('a', 'b')
isAuthenticated()Have current users certified
interview question: What is the execution principle of @PreAuthorize?

With method security enabled, Spring Security creates proxies for protected Spring beans based on Spring AOP. Before calling the method, AuthorizationManagerBeforeMethodInterceptor reads the SpEL expression of @PreAuthorize and makes authorization judgment based on the current Authentication.

The target method will not be called until the verification passes; AccessDeniedException will be thrown when the verification fails. In the HTTP request chain, this exception is usually converted into a 403 Forbidden response.

key considerations

  1. Successful authentication does not mean that you have all permissions. Authentication and authorization must be configured separately.
  2. 401 indicates that it has not been certified, and 403 indicates that it has been certified but lacks authority.
  3. The JWT filter must first verify the signature and expiration date before creating a certified Authentication.
  4. Don't trust roles or permissions submitted directly by clients.
  5. Do not return the password, password digest, and key in the Token or response body.
  6. CORS, CSRF, and XSS are different security issues and cannot be substituted for each other.
  7. The production system should use HTTPS and configure a reasonable Token validity period, refresh and revocation mechanism.
  8. Method-level authorization is suitable for protecting key operations at the Service layer and cannot just rely on whether the front-end button is displayed.

If you enjoyed this, leave a comment~

... Page views
© 2026 跨越星轨的客 @Hoshiumi
Powered by theme astro-koharu · Inspired by Shoka