Spring Security RBAC with JWT: A Practical Guide to Stateless Authentication and Authorization

This article explains how to implement RBAC with Spring Security and JWT. It focuses on user modeling, authentication filters, role-based authorization, and stateless session management to solve common issues such as scattered API permissions, hard-to-maintain login state, and poor extensibility. Keywords: Spring Security, RBAC, JWT.

Technical specifications are summarized below

Parameter Description
Core language Java
Core frameworks Spring Boot, Spring Security
Authentication protocol JWT Bearer Token
Persistence layer Spring Data JPA
Password scheme BCrypt
JWT dependency JJWT
Star count Not provided in the original article
Use cases Admin backends, decoupled front end and back end, API authorization

RBAC shifts permission control from direct user assignment to role-based mediation

RBAC, or Role-Based Access Control, is not about assigning permissions directly to users. Instead, you define roles first, bind permissions to those roles, and then assign roles to users. This model upgrades permission management from individual operations to rule-based operations, making it a better fit for organizational systems.

In business systems, the common relationship is a three-layer mapping of user, role, and permission. A user can have multiple roles, and a role can aggregate multiple permissions. When responsibilities change, you only need to adjust the role instead of updating authorization rules one API at a time.

User   → Role           → Permission
USER   → Employee Role  → View work orders
ADMIN  → Admin Role     → View/Edit/Delete work orders

This structure illustrates the minimal authorization chain in RBAC. At its core, the role acts as the permission aggregation layer.

RBAC provides immediate benefits in enterprise systems

First, it reduces maintenance costs. Second, it improves permission consistency. Third, it makes auditing easier. For systems that include admin portals, content review, or order operations, RBAC is the most common foundational authorization model.

Spring Security handles authentication and authorization through the filter chain

Spring Security is fundamentally a security framework built on a Filter Chain. After a request enters the application, it passes through multiple filters in sequence for authentication, authorization, exception handling, and more, which ultimately determines whether the request is allowed.

It mainly solves two problems: authentication answers “who are you,” while authorization answers “what are you allowed to do.” RBAC serves as the authorization mapping layer here, while Spring Security executes the decision logic.

// Authentication: validate whether the identity is legitimate
Authentication authentication = authenticationManager.authenticate(token);

// Authorization: determine whether access is allowed based on roles
boolean allowed = authorities.stream()
    .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); // Check whether the user has the admin role

This code demonstrates the separation of responsibilities between authentication and authorization.

A typical implementation should organize code around six modules

In a Spring Boot project, you should split the authorization system into six layers: config, controller, service, repository, model, and utils. This approach decouples filters, security configuration, user loading, and token utilities.

config/      Security configuration, JWT filters
controller/  Login, registration, and business APIs
service/     User loading and business services
repository/  Data access
model/       User and role entities
utils/       JWT and password utilities

This directory structure works well for small to medium-sized admin systems and also makes it easier to expand into menu permissions and method-level authorization later.

User entities should prioritize clear role representation

At a minimum, the user table should include username, password, role, created time, and updated time. If the current permission model is lightweight, you can start by storing roles as an enum. If you later need multiple roles, multiple permissions, or multi-tenancy, you can evolve to separate user, role, permission, and mapping tables.

@Entity
@Table(name = "users")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role; // Store roles as strings to avoid ambiguity caused by numeric enums
}

This model definition ensures that the role field remains readable and maintainable in the database.

Passwords must use BCrypt instead of plaintext storage

BCrypt includes a built-in salt mechanism, so the same password produces different hashes across multiple encryptions. This significantly reduces the risk of credential stuffing and rainbow table attacks. Hashing during registration and matching during login are the most basic and most important security requirements.

public class PasswordUtil {
    private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder();

    public static String encode(String rawPassword) {
        return ENCODER.encode(rawPassword); // Perform irreversible hashing on the raw password during registration
    }

    public static boolean matches(String rawPassword, String encodedPassword) {
        return ENCODER.matches(rawPassword, encodedPassword); // Verify whether the password matches during login
    }
}

This utility class provides a unified entry point for password hashing and verification.

JWT enables the system to move to stateless authentication

After adopting JWT, the server no longer depends on Session storage to keep login state. Once the user logs in successfully, they receive a token. Every subsequent request includes that token in the Authorization header, and the server only validates and parses it.

This approach is especially suitable for decoupled front-end and back-end architectures, gateway forwarding, and microservice API authorization. However, do not hardcode secrets in production. Store them in environment variables or a centralized configuration service.

@Component
public class JwtUtil {
    private static final String SECRET = "change_me_in_prod";
    private static final long EXPIRE_MS = 24 * 60 * 60 * 1000;

    public String generateToken(String username) {
        return Jwts.builder()
            .setSubject(username) // Write the username into the JWT subject field
            .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_MS))
            .signWith(SignatureAlgorithm.HS256, SECRET)
            .compact();
    }
}

This code generates the JWT used for API access.

UserDetailsService bridges database users to Spring Security

Spring Security does not understand your user entity directly. It only understands UserDetails. That is why you must implement UserDetailsService and convert your database object into a security object that the framework can consume.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) {
        UserEntity user = repository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())) // Add the ROLE_ prefix to match hasRole checks
        );
    }
}

The key detail in this adapter code is the role prefix. Without it, hasRole("ADMIN") will not match correctly.

The JWT filter writes token identity into the security context

The filter is the execution core of the entire solution. After each request enters the system, it reads the Bearer Token from the request header, validates the signature and expiration, loads user information, and writes the authentication result into SecurityContextHolder.

@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws ServletException, IOException {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            String token = bearer.substring(7); // Extract the actual JWT value
            String username = jwtUtil.extractUsername(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(auth); // Store the authentication result for downstream authorization
        }
        chain.doFilter(request, response);
    }
}

This filter allows Spring Security to identify the current user even without Session-based state.

Security configuration should explicitly define public endpoints and role boundaries

The final step is to define which endpoints are public, which require USER, and which allow only ADMIN. In JWT-based projects, you typically also disable CSRF and set SessionCreationPolicy to STATELESS.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf.disable()) // Disable CSRF in typical JWT stateless scenarios
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/login", "/api/auth/register").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers("/api/workspace/**").hasAnyRole("USER", "ADMIN")
            .anyRequest().authenticated())
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

This configuration clearly defines the permission boundaries for authentication endpoints, admin APIs, and regular business APIs in one place.

A complete request follows a fixed processing path

During registration, the system hashes the password and stores the user record. During login, it verifies the password and issues a JWT. When accessing business APIs, the client includes the token in the request header, the filter resolves the user identity, and then the authorization rules determine whether the role matches the target endpoint.

Register → BCrypt hashing → Save user
Login → Verify password → Generate JWT
Request API → Include Bearer Token → Filter resolves identity
Authorization check → Allow if role matches, otherwise return 403

This flow is the minimal closed loop of Spring Security + JWT + RBAC.

The image on the original page is decorative branding rather than an architecture diagram

C Zhidao

The image is a product logo rather than a technical diagram, so no visual analysis is added.

Production deployments should still add several critical security details

First, never hardcode the JWT secret. Second, HTTPS must be enabled. Third, adding a refresh token mechanism is strongly recommended. Fourth, if your permission granularity evolves to button-level or data-level control, you should move from a single-role enum to a multi-table user-role-permission model.

Once API authorization becomes stable, you can also add @PreAuthorize for method-level access control to address the limitations of overly coarse URL-based authorization.

FAQ

Q1: What is the core difference between RBAC and assigning permissions directly to users?
A1: RBAC aggregates permissions through roles, which reduces repetitive configuration and fits organizational systems well. Direct user-level permission assignment is flexible, but it has a high maintenance cost and can easily become unmanageable.

Q2: Why do roles in Spring Security usually need the ROLE_ prefix?
A2: This is the framework’s default convention. hasRole("ADMIN") actually matches ROLE_ADMIN. Without the prefix, your authorization rules may always fail.

Q3: If JWT already stores the username, why do many implementations still query the database?
A3: Because password state, roles, and disabled or banned flags may have changed in the database. Reloading the user ensures that authorization decisions use the latest state instead of trusting an outdated token alone.

AI Readability Summary: This article systematically reconstructs a complete RBAC implementation based on Spring Security, covering the user model, password hashing, JWT authentication, filter injection, and permission rule configuration. It helps developers quickly implement a stateless authentication and role-based authorization system.