Backend Development 13 min read

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

<code>&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter-data-jpa&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
  &lt;groupId&gt;io.jsonwebtoken&lt;/groupId&gt;
  &lt;artifactId&gt;jjwt-api&lt;/artifactId&gt;
  &lt;version&gt;0.12.6&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
  &lt;groupId&gt;io.jsonwebtoken&lt;/groupId&gt;
  &lt;artifactId&gt;jjwt-impl&lt;/artifactId&gt;
  &lt;version&gt;0.12.6&lt;/version&gt;
&lt;/dependency&gt;
</code>

Data source and JPA configuration:

<code>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
</code>

2.2 Entity Definitions

<code>@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&lt;Permission&gt; permissions = new HashSet&lt;&gt;();
  // getters, setters
}
</code>
<code>@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
}
</code>

2.3 DAO & Service

<code>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);
}
</code>
<code>@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);
  }
}
</code>
<code>@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));
  }
}
</code>

2.4 JWT Utility

<code>@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();
  }
}
</code>

2.5 Interceptors

<code>@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;
  }
}
</code>
<code>@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);
  }
}
</code>

2.6 Interceptor Registration

<code>@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/**");
  }
}
</code>

2.7 Custom Annotation

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

2.8 Controllers

<code>@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);
  }
}
</code>
<code>@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";
  }
}
</code>

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.

<code>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;
}
</code>

Example endpoint using a SpEL expression:

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

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.

backend developmentSPELpermissionSpring 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

login 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.