Master Fine-Grained Permission Control in Spring Boot 3 with JWT and SpEL

This article demonstrates how to implement fine‑grained permission checks in Spring Boot 3 without using Spring Security, by creating custom HandlerInterceptors, JWT utilities, DAO and service layers, and integrating SpEL expressions for dynamic authorization, complete with code snippets and test results.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Master Fine-Grained Permission Control in Spring Boot 3 with JWT and SpEL

1. Introduction

We implement fine‑grained permission control in Spring Boot 3 without using other security frameworks (e.g., Spring Security). Permissions are checked via a custom HandlerInterceptor and logged, using JPA for user and permission data.

2. Practical Example

2.1 Dependency Management & Configuration

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<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>
</dependency>

Data source and JPA configuration:

spring:
  datasource:
    driverClassName: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/ddd
    username: root
    password: xxxooo
    type: com.zaxxer.hikari.HikariDataSource
  jpa:
    generateDdl: false
    hibernate:
      ddlAuto: none
    openInView: true
    show-sql: true

2.2 Entity Definitions

@Entity
@Table(name = "t_user")
public class User {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String username;
  private String password;
  @OneToMany(cascade = CascadeType.ALL, mappedBy = "user", fetch = FetchType.EAGER)
  private Set<Permission> permissions = new HashSet<>();
  // getters, setters
}
@Entity
@Table(name = "t_permission")
public class Permission {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String name;
  @ManyToOne(cascade = CascadeType.REFRESH)
  @JoinColumn(name = "uid")
  private User user;
  // getters, setters
}

2.3 DAO & Service

public interface UserRepository extends JpaRepository<User, Long> {
  User findByUsernameAndPassword(String username, String password);
}
public interface PermissionRepository extends JpaRepository<Permission, Long> {
  List<Permission> findByUser(User user);
}
@Service
public class UserService {
  private final UserRepository userRepository;
  private final JwtUtil jwtUtil;
  public UserService(UserRepository userRepository, JwtUtil jwtUtil) {
    this.userRepository = userRepository;
    this.jwtUtil = jwtUtil;
  }
  public String login(String username, String password) {
    User user = userRepository.findByUsernameAndPassword(username, password);
    if (user == null) {
      throw new RuntimeException("用户名或密码错误");
    }
    return jwtUtil.generateToken(user.getId());
  }
  public User queryUser(Long userId) {
    return userRepository.findById(userId).orElse(null);
  }
}
@Service
public class PermissionService {
  private final PermissionRepository permissionRepository;
  public PermissionService(PermissionRepository permissionRepository) {
    this.permissionRepository = permissionRepository;
  }
  public List<Permission> findPermissions(Long userId) {
    return permissionRepository.findByUser(new User(userId));
  }
}

2.4 JWT Utility

@Component
public class JwtUtil {
  @Value("${jwt.secret}")
  private String secret;
  @Value("${jwt.expiration}")
  private Long expiration;
  public String generateToken(Long userId) {
    Map<String, Object> claims = new HashMap<>();
    return createToken(claims, String.valueOf(userId));
  }
  public Long getUserIdFromToken(String token) {
    return Long.valueOf(getClaimFromToken(token, Claims::getSubject));
  }
  private <T> T getClaimFromToken(String token, Function<Claims, T> resolver) {
    Claims claims = getAllClaimsFromToken(token);
    return resolver.apply(claims);
  }
  private Claims getAllClaimsFromToken(String token) {
    return Jwts.parser()
      .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
      .build()
      .parse(token)
      .getPayload();
  }
  private String createToken(Map<String, Object> claims, String subject) {
    return Jwts.builder()
      .claims()
      .add(claims)
      .subject(subject)
      .issuedAt(new Date(System.currentTimeMillis()))
      .expiration(new Date(System.currentTimeMillis() + expiration * 1000))
      .and()
      .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
      .compact();
  }
}

2.5 Interceptors

@Component
public class TokenInterceptor implements HandlerInterceptor {
  private static final String TOKEN_KEY = "X-TOKEN";
  private final JwtUtil jwtUtil;
  private final UserService userService;
  public TokenInterceptor(JwtUtil jwtUtil, UserService userService) {
    this.jwtUtil = jwtUtil;
    this.userService = userService;
  }
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String token = request.getHeader(TOKEN_KEY);
    if (!StringUtils.hasLength(token)) {
      token = request.getParameter("token");
    }
    if (!StringUtils.hasLength(token)) {
      response.getWriter().println("Invalid token");
      return false;
    }
    Long userId = jwtUtil.getUserIdFromToken(token);
    SecurityContext.set(userService.queryUser(userId));
    return true;
  }
}
@Component
public class AuthInterceptor implements HandlerInterceptor {
  private final PermissionService permissionService;
  public AuthInterceptor(PermissionService permissionService) {
    this.permissionService = permissionService;
  }
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    if (handler instanceof HandlerMethod) {
      HandlerMethod hm = (HandlerMethod) handler;
      PreAuthorize pre = hm.getMethodAnnotation(PreAuthorize.class);
      if (pre != null) {
        User user = SecurityContext.get();
        if (user == null) {
          response.getWriter().write("Goto login");
          return false;
        }
        List<String> allowed = Arrays.asList(pre.value());
        List<Permission> perms = permissionService.findPermissions(user.getId());
        if (!hasAllowedPermission(allowed, perms)) {
          response.getWriter().write("Access denied");
          return false;
        }
      }
    }
    return true;
  }
  private boolean hasAllowedPermission(List<String> allowed, List<Permission> perms) {
    List<String> names = perms.stream().map(Permission::getName).collect(Collectors.toList());
    return allowed.stream().anyMatch(names::contains);
  }
}

2.6 Interceptor Registration

@Component
public class InterceptorConfig implements WebMvcConfigurer {
  private final TokenInterceptor tokenInterceptor;
  private final AuthInterceptor authInterceptor;
  public InterceptorConfig(TokenInterceptor tokenInterceptor, AuthInterceptor authInterceptor) {
    this.tokenInterceptor = tokenInterceptor;
    this.authInterceptor = authInterceptor;
  }
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(tokenInterceptor).order(-2).addPathPatterns("/api/**");
    registry.addInterceptor(authInterceptor).order(-1).addPathPatterns("/api/**");
  }
}

2.7 Custom Annotation

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

2.8 Controllers

@RestController
@RequestMapping("/users")
public class UserController {
  private final UserService userService;
  public UserController(UserService userService) {
    this.userService = userService;
  }
  @GetMapping("/login")
  public String login(String username, String password) {
    return userService.login(username, password);
  }
}
@RestController
@RequestMapping("/api")
public class ApiController {
  @PreAuthorize("api.save")
  @GetMapping("/save")
  public Object save() {
    return "save";
  }
  @PreAuthorize("api.update")
  @GetMapping("/update")
  public Object update() {
    return "update";
  }
}

2.9 SpEL Integration

AuthInterceptor is enhanced to evaluate SpEL expressions. If the expression returns a string, it is treated as a list of required permissions; if it returns a boolean, the boolean result determines access.

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  if (handler instanceof HandlerMethod hm) {
    PreAuthorize pre = hm.getMethodAnnotation(PreAuthorize.class);
    if (pre != null) {
      User user = SecurityContext.get();
      if (user == null) {
        response.getWriter().write("Goto login");
        return false;
      }
      String expr = pre.value();
      MethodBasedEvaluationContext ctx = createContext(hm);
      Object value = parser.parseExpression(expr).getValue(ctx);
      if (value instanceof String) {
        List<String> allowed = Arrays.asList(value.toString());
        List<Permission> perms = permissionService.findPermissions(user.getId());
        if (!hasAllowedPermission(allowed, perms)) {
          response.getWriter().write("Access denied");
          return false;
        }
      } else if (value instanceof Boolean ret) {
        if (!ret) {
          response.getWriter().write("Access denied");
          return false;
        }
        return ret;
      }
    }
  }
  return true;
}

Example endpoint using a SpEL expression:

@PreAuthorize("username eq 'admin'")
@GetMapping("/delete")
public Object delete() {
  return "delete";
}

When the logged‑in user’s username is admin, the request is allowed; otherwise it is denied.

Test screenshots (omitted) show token acquisition, access without token (error), and successful calls after providing the JWT.

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.

Backend DevelopmentpermissionSpring BootJWThandlerinterceptor
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

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.