Spring Boot 3 + Spring Security 6 + JWT: Production-Ready Auth & Authorization Guide

This article walks through building a complete JWT-based authentication and authorization system with Spring Boot 3 and Spring Security 6, covering RBAC database design, token issuance and validation, filter chain configuration, permission annotations, refresh tokens, logout blacklisting, and Vue 3 frontend integration.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Spring Boot 3 + Spring Security 6 + JWT: Production-Ready Auth & Authorization Guide

Why Session Authentication Breaks in Frontend-Backend Separation

The author inherited a backend admin system using session.setAttribute("user", user). After the frontend migrated to Vue with independent deployment (port 5173 vs backend 8080), four problems emerged:

Local development required re-login on every refresh because cross-origin cookies (JSESSIONID) were not sent.

Production with two load-balanced servers caused random logouts since sessions lived in single-server memory.

Mini-programs and native apps couldn't integrate because they lack cookie support.

API endpoints were exposed; only frontend buttons were hidden.

Conclusion: After frontend-backend separation, authentication must shift from "server stores state" to "client carries credentials."

Session vs JWT Stateless Comparison

State storage: Traditional Session – Server memory / Redis; JWT Stateless – Client (token self-contained)

Cross-origin: Traditional Session – Requires Cookie + CORS + SameSite; JWT Stateless – Authorization header, naturally cross-origin

Multi-client: Traditional Session – Browser-centric; JWT Stateless – Browser / App / Mini-program / third-party

Horizontal scaling: Traditional Session – Needs session sharing or sticky routing; JWT Stateless – Native support, any instance can verify

Logout: Traditional Session – Delete session, immediate effect; JWT Stateless – Token self-contained, requires extra blacklist

Performance: Traditional Session – May query session store each request; JWT Stateless – Single signature verification

Warning: JWT is not a drop-in replacement for sessions. If you need instant revocation (kick user, force password change), you must add server-side state (see blacklist section).

Overall Request Flow

Frontend-backend authentication flow
Frontend-backend authentication flow
In one sentence: On login, issue a token; on subsequent requests, the client sends the token; a filter restores the token into a "current user" in the SecurityContext; finally, permission annotations decide access.

JWT Structure: Three-Part String

JWT structure
JWT structure

Example token:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJJZCI6MSwiYXV0aG9yaXRpZXMiOlsic3lzdGVtOnVzZXI6bGlzdCJdLCJpYXQiOjE3MjcyNjA4MDAsImV4cCI6MTcyNzM0NzIwMH0.8Z3kQ2lR6vU8xJ1pT0nM4bK9wL2sH5fD7gY6cA3eB1o

Header: {"alg":"HS256","typ":"JWT"} – Declares signing algorithm

Payload: sub / userId / authorities / iat / exp – Carries user info ( Base64, not encrypted )

Signature: HMAC(Header+Payload, secret) – Prevents tampering; server recomputes and compares

Payload is only Base64 encoded, not encrypted — browser atob() can decode. Never put passwords, IDs, secrets in JWT.

Signature only guarantees integrity, not confidentiality — must use HTTPS and keep token expiry short.

Environment Setup

Spring Boot 3.2+ (Spring Security 6.x), JDK 17+. Security 6 removed WebSecurityConfigurerAdapter entirely , and antMatchers is gone; many 5.x tutorials will fail to compile.

Key dependencies (Maven):

<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>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
  </dependency>
  <!-- JWT: api at compile, impl/jackson at runtime -->
  <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>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
</dependencies>
application.yml

(HS256 secret must be ≥32 bytes / 256 bits, otherwise WeakKeyException):

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/security_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: update  # demo only; production use Flyway/Liquibase
    show-sql: true
    open-in-view: false

jwt:
  secret: java-workshop-jwt-secret-key-must-be-256-bits-at-least
  expiration: 7200000  # access token: 2 hours
  header: Authorization
  prefix: "Bearer "

Database: Standard RBAC Five Tables

sys_user        -- user
sys_role        -- role
sys_menu        -- menu (permission identifier perms)
sys_user_role   -- user-role
sys_role_menu   -- role-menu

DDL (MySQL, InnoDB, utf8mb4) includes tables for users, roles, menus, and join tables. Initial data inserts admin/user with BCrypt password $2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi (plain: 123456), roles ADMIN/USER, permissions system:user:list, system:user:add, system:user:delete, and mappings.

Entity example (SysUser) with transient fields for roles and permissions to avoid N+1:

@Data
@Entity
@Table(name = "sys_user")
public class SysUser {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String nickname;
    private Integer status;
    @Transient
    private List<String> roles = new ArrayList<>(); // role codes: ROLE_ADMIN
    @Transient
    private Set<String> permissions = new HashSet<>(); // permission identifiers: system:user:list
}

Repository fetches roles and permissions in one query each:

public interface SysUserRepository extends JpaRepository<SysUser, Long> {
    @Query("select u from SysUser u where u.username = :username")
    Optional<SysUser> findByUsername(@Param("username") String username);

    @Query(value = "select concat('ROLE_', r.role_code) from sys_role r " +
            "join sys_user_role ur on ur.role_id = r.id where ur.user_id = :userId",
            nativeQuery = true)
    List<String> findRoleCodesByUserId(@Param("userId") Long userId);

    @Query(value = "select m.perms from sys_menu m " +
            "join sys_role_menu rm on rm.menu_id = m.id " +
            "join sys_user_role ur on ur.role_id = rm.role_id " +
            "where ur.user_id = :userId and m.perms is not null",
            nativeQuery = true)
    Set<String> findPermsByUserId(@Param("userId") Long userId);
}

JWT Utility: Issue, Parse, Validate

JwtTokenProvider

component using jjwt 0.12.x API (note breaking changes from 0.11.x):

@Component
public class JwtTokenProvider {
    private final SecretKey key;
    private final long expiration;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.expiration}") long expiration) {
        // HS256 requires key ≥32 bytes, else Keys.hmacShaKeyFor throws
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration = expiration;
    }

    /** Issue token: embed userId and permissions so filter avoids DB lookup */
    public String createToken(LoginUser loginUser) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", loginUser.getUserId());
        claims.put("nickname", loginUser.getNickname());
        claims.put("authorities", new ArrayList<>(loginUser.getPermissions()));
        Date now = new Date();
        return Jwts.builder()
                .claims(claims)                 // 0.12.x new style
                .subject(loginUser.getUsername())
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expiration))
                .signWith(key)                  // algorithm inferred from key length
                .compact();
    }

    /** Parse and validate signature + expiry */
    public Claims parseToken(String token) {
        return Jwts.parser()
                .verifyWith(key)                // 0.12.x: verifyWith replaces setSigningKey
                .build()
                .parseSignedClaims(token)       // 0.12.x: parseSignedClaims replaces parseClaimsJws
                .getPayload();
    }

    /** Restore Authentication from token */
    public Authentication getAuthentication(String token) {
        Claims claims = parseToken(token);
        String username = claims.getSubject();
        Long userId = claims.get("userId", Long.class);
        @SuppressWarnings("unchecked")
        List<String> permissions = claims.get("authorities", List.class);
        List<GrantedAuthority> authorities = permissions == null
                ? List.of()
                : permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        LoginUser principal = new LoginUser(userId, username,
                claims.get("nickname", String.class), authorities);
        return new UsernamePasswordAuthenticationToken(principal, null, authorities);
    }

    /** Only check if token itself is valid */
    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false; // signature error, expired, malformed → invalid
        }
    }
}

jjwt 0.12.x API changes (common copy-paste pitfall): Jwts.parserBuilder() →

Jwts.parser()
.setSigningKey(key)

→

.verifyWith(key)
.parseClaimsJws(token).getBody()

→

.parseSignedClaims(token).getPayload()
.setClaims(map)

→

.claims(map)
SignatureAlgorithm.HS256

(manual) → Inferred from key length

Spring Security Main Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // 6.x: enables @PreAuthorize/@PostAuthorize (replaces @EnableGlobalMethodSecurity)
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint authenticationEntryPoint;
    private final JwtAccessDeniedHandler accessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // strength 10, balance perf/security
    }

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Stateless frontend-backend: CSRF targets cookie/session, not applicable
            .csrf(AbstractHttpConfigurer::disable)
            // CORS must be explicit, otherwise preflight (OPTIONS) blocked
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            // Never create HttpSession
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Context lives only for current request, completely prevent session writes
            .securityContext(sc -> sc.securityContextRepository(
                    new RequestAttributeSecurityContextRepository()))
            // Disable default form login / Basic auth popup
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            // URL-level authorization: coarse allow, fine-grained via annotations
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                    .requestMatchers("/auth/**", "/public/**").permitAll()
                    .requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
                    .anyRequest().authenticated()
            )
            // Unified JSON responses for unauthenticated / unauthorized
            .exceptionHandling(e -> e
                    .authenticationEntryPoint(authenticationEntryPoint) // 401: missing/invalid token
                    .accessDeniedHandler(accessDeniedHandler)           // 403: authenticated but insufficient permission
            )
            .logout(AbstractHttpConfigurer::disable) // logout via custom /auth/logout
            // ⚠️ Must insert before UsernamePasswordAuthenticationFilter
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOriginPatterns(List.of("*"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("Authorization"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

Why JWT filter must precede UsernamePasswordAuthenticationFilter ? The AuthorizationFilter (last gate) reads SecurityContext; if our filter hasn't run, context is empty → immediate 401. Symptom: "Token is correct but all endpoints return 401."

Filter chain
Filter chain

Login User Object & UserDetailsService

LoginUser

implements UserDetails, holds userId, username, nickname, authorities. Password getter returns empty string (token restoration doesn't need password). Includes hasPermission(String perm) helper for annotation use. UserDetailsServiceImpl loads user by username, checks status, fetches roles+permissions in one go, builds Spring's User with BCrypt password from DB. Roles and permissions go into same authorities collection.

Design choice: Roles and permissions share the same authorities collection. Benefit: @PreAuthorize("hasRole('ADMIN')") and custom permission checks use same data. Note: roles must carry ROLE_ prefix; hasRole('ADMIN') equals hasAuthority('ROLE_ADMIN') .

JWT Filter: Restore Token to Current User

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String token = resolveToken(request);
        if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            try {
                if (jwtTokenProvider.validateToken(token)) {
                    Authentication auth = jwtTokenProvider.getAuthentication(token);
                    // Optional: re-check account status (prevent valid token but disabled user)
                    UserDetails details = userDetailsService.loadUserByUsername(auth.getName());
                    if (details.isEnabled()) {
                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContext context = SecurityContextHolder.createEmptyContext();
                        context.setAuthentication(authentication);
                        SecurityContextHolder.setContext(context);
                    }
                }
            } catch (Exception e) {
                log.warn("JWT validation failed: {}", e.getMessage());
                // ⚠️ Swallow exception, clear context, let 401 handler respond
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

Three critical details:

Must check getAuthentication() == null to avoid overwriting existing authentication (e.g., Basic auth or internal calls).

Catch exceptions, call clearContext(), delegate to AuthenticationEntryPoint for 401 response; don't write response body in filter. OncePerRequestFilter guarantees single execution per request (forward scenarios won't re-parse).

Unified exception responses (401/403) return JSON:

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException e) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":401,\"msg\":\"未认证或 Token 已失效,请重新登录\"}");
    }
}

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException e) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":403,\"msg\":\"没有权限访问该资源\"}");
    }
}

Login Endpoint

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsServiceImpl userDetailsService;

    @PostMapping("/login")
    public R<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) {
        // 1. Trigger authentication: UserDetailsService + BCrypt compare, throws BadCredentialsException on failure
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        // 2. Reload to get userId (needed for token)
        SysUser user = userDetailsService.getByUsername(request.getUsername());
        LoginUser loginUser = new LoginUser(
                user.getId(), user.getUsername(), user.getNickname(), authentication.getAuthorities());
        // 3. Issue token
        String token = jwtTokenProvider.createToken(loginUser);
        Map<String, Object> data = new HashMap<>();
        data.put("token", token);
        data.put("tokenType", "Bearer");
        data.put("expiresIn", 7200);
        data.put("username", user.getUsername());
        data.put("permissions", loginUser.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList());
        return R.ok(data);
    }

    @PostMapping("/logout")
    public R<Void> logout() {
        SecurityContextHolder.clearContext();
        // Advanced: add current token to Redis blacklist (see section 12)
        return R.ok();
    }
}

Global exception handler maps BadCredentialsException → 401 "username or password wrong" (don't distinguish user-not-found vs wrong password), DisabledException / LockedException → 403 "account disabled", AccessDeniedException → 403 "no permission".

Permission Annotations: The Core

Permission annotation flow
Permission annotation flow

11.1 Three Usage Patterns

@RestController
@RequestMapping("/system/user")
@RequiredArgsConstructor
public class SysUserController {
    private final SysUserService userService;

    // ① Role check: hasRole auto-adds ROLE_ prefix
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/list")
    public R<List<SysUser>> list() { return R.ok(userService.list()); }

    // ② Permission identifier check (RuoYi style): @ss is bean name in container
    @PreAuthorize("@ss.hasPermi('system:user:add')")
    @PostMapping
    public R<Void> add(@RequestBody SysUser user) { userService.save(user); return R.ok(); }

    // ③ Data-level check: only modify own profile, admin exempt
    @PreAuthorize("hasRole('ADMIN') or #id == principal.userId")
    @PutMapping("/{id}")
    public R<Void> update(@PathVariable Long id, @RequestBody SysUser user) {
        userService.updateById(user);
        return R.ok();
    }
}

11.2 Custom Permission Bean

@Component("ss")  // ⚠️ Bean name = @ss in SpEL
@RequiredArgsConstructor
public class PermissionService {
    public boolean hasPermi(String permission) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) return false;
        // Super admin pass (adjust per business)
        if (auth.getAuthorities().stream().anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()))) return true;
        return auth.getAuthorities().stream()
                .anyMatch(a -> permission.equals(a.getAuthority()));
    }
    public boolean hasAnyPermi(String... permissions) {
        return Arrays.stream(permissions).anyMatch(this::hasPermi);
    }
    public boolean hasRole(String role) {
        return hasPermi("ROLE_" + role);
    }
}

11.3 Optional: Custom Annotation via AOP

SpEL in @PreAuthorize cannot read custom annotation attributes directly. To achieve @RequiresPermissions("system:user:add"), use an aspect:

@Aspect
@Component
@RequiredArgsConstructor
public class RequiresPermissionsAspect {
    private final PermissionService permissionService;
    @Before("@annotation(rp)")
    public void check(RequiresPermissions rp) {
        if (!permissionService.hasPermi(rp.value())) {
            throw new AccessDeniedException("没有权限:" + rp.value());
        }
    }
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermissions { String value(); }

Then controller becomes clean:

@RequiresPermissions("system:user:delete") @DeleteMapping("/{id}")

.

Recommendation: Small projects: @PreAuthorize("@ss.hasPermi('...')") is enough, zero extra code. Many permission identifiers, fear typos: custom annotation + aspect, also enables permission-point scanning/export.

11.4 Access Current Logged-in User

// Way 1: Utility (anywhere)
public static LoginUser currentUser() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || !(auth.getPrincipal() instanceof LoginUser)) return null;
    return (LoginUser) auth.getPrincipal();
}

// Way 2: Parameter injection (preferred, testable)
@GetMapping("/profile")
public R<Profile> profile(@AuthenticationPrincipal LoginUser user) {
    return R.ok(profileService.get(user.getUserId()));
}

Advanced: Refresh Token & Logout Blacklist

JWT's biggest weakness: once issued, cannot be recalled . Two patches:

12.1 Dual Token Silent Refresh

// access: 2h, in memory; refresh: 7d, stored in Redis
@PostMapping("/refresh")
public R<Map<String, String>> refresh(@RequestHeader("Refresh-Token") String refreshToken) {
    Claims claims = jwtTokenProvider.parseToken(refreshToken);
    String redisKey = "refresh:" + claims.getSubject();
    if (!refreshToken.equals(stringRedisTemplate.opsForValue().get(redisKey))) {
        return R.fail(401, "Refresh Token 无效,请重新登录");
    }
    LoginUser user = buildLoginUser(claims.getSubject());
    return R.ok(Map.of("token", jwtTokenProvider.createToken(user)));
}

Frontend Axios interceptor: on 401 → use refresh token to get new access token → replay original request, user unaware.

12.2 Logout / Force Offline: Redis Blacklist

@PostMapping("/logout")
public R<Void> logout(HttpServletRequest request) {
    String token = jwtTokenProvider.resolveToken(request);
    Claims claims = jwtTokenProvider.parseToken(token);
    long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
    if (ttl > 0) {
        // Store only short digest: key = "blacklist:" + md5(token)
        redisTemplate.opsForValue().set(
                "blacklist:" + DigestUtils.md5DigestAsHex(token.getBytes()),
                "1", ttl, TimeUnit.MILLISECONDS);
    }
    SecurityContextHolder.clearContext();
    return R.ok();
}

Filter adds check:

if (Boolean.TRUE.equals(redisTemplate.hasKey(
        "blacklist:" + DigestUtils.md5DigestAsHex(token.getBytes())))) {
    SecurityContextHolder.clearContext(); // logged out = unauthenticated
    chain.doFilter(request, response);
    return;
}

Frontend Integration (Vue 3 + Axios)

// request.ts
const service = axios.create({ baseURL: '/api', timeout: 10000 })

service.interceptors.request.use(config => {
    const token = useUserStore().token
    if (token) config.headers.Authorization = `Bearer ${token}` // ⚠️ space after Bearer
    return config
})

service.interceptors.response.use(
    res => res.data,
    error => {
        const { status, data } = error.response ?? {}
        if (status === 401) { userStore.logout(); router.push('/login') }
        if (status === 403) { ElMessage.error('没有权限访问该资源') }
        return Promise.reject(error)
    }
)

Menu/button visibility (backend returns permission list, frontend shows/hides — only UX, not security ):

export const hasPermi = (perm: string) => useUserStore().permissions.includes(perm)
// <el-button v-if="hasPermi('system:user:add')">新增</el-button>

Again: frontend button hiding is only UX; real defense is backend permission annotations.

Full Verification: Run It

# 1. Login to get token
curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'
# → {"code":200,"data":{"token":"eyJhbGciOiJIUzI1NiJ9...","permissions":["ROLE_ADMIN","system:user:list"]}}

# 2. Access protected endpoint with token
TOKEN=eyJhbGciOiJIUzI1NiJ9...
curl http://localhost:8080/system/user/list -H "Authorization: Bearer $TOKEN"
# → 200 + user list

# 3. Without token
curl http://localhost:8080/system/user/list
# → 401 {"code":401,"msg":"未认证或 Token 已失效,请重新登录"}

# 4. zhangsan (regular user, only system:user:list) calls delete
curl -X DELETE http://localhost:8080/system/user/2 -H "Authorization: Bearer $USER_TOKEN"
# → 403 {"code":403,"msg":"没有权限访问该资源"}

# 5. Tamper token last char
curl http://localhost:8080/system/user/list -H "Authorization: Bearer ${TOKEN}x"
# → 401 (signature verification failed)

# 6. Preflight (CORS)
curl -X OPTIONS http://localhost:8080/system/user/list \
  -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: GET" -i
# → 204 with Access-Control-Allow-Origin

Core code checklist: SecurityConfig / JwtTokenProvider / JwtAuthenticationFilter / UserDetailsServiceImpl / PermissionService / AuthController — six classes to run fully; rest is business logic.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

AuthenticationJWTAuthorizationRBACVue 3Refresh TokenSpring Boot 3Spring Security 6
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.