Implementing JWT-Based Authentication and Authorization with Spring Security

This guide provides a comprehensive walkthrough for building robust authentication and authorization mechanisms using Spring Security with JWT tokens, Redis session storage, and database-backed user management.

Core Concepts of Access Control

Any system involving user participation requires access control mechanisms as part of its security architecture. Access control governs user interactions with system resources, enforcing security policies that restrict users to only their authorized operations. The framework comprises two fundamental components: identity verification (authentication) and permission validation (authorization).

Identity Verification

Consider modern applications like banking apps or streaming platforms. Before accessing personal financial data or premium content, users must verify their identity—typically through credentials, biometrics, or one-time codes. This verification process establishes trust between the user and system.

Common authentication methods include password-based login, QR code scanning, SMS verification, and fingerprint recognition. The primary goal is to validate whether a user is genuinely who they claim to be before granting any resource access.

Permission Validation

After successful authentication, authorization determines what actions a user can perform. For instance, a streaming platform might allow all users to browse content, but only subscribers with specific tiers can download videos for offline viewing. This granular control protects sensitive data and functionality.

Authorization occurs post-authentication, evaluating user privileges against resource access requirements to permit or deny operations.

Spring Security Overview

In the Java ecosystem, Spring Security stands as the de facto standard for application security. While alternatives like Apache Shiro exist, Spring Security's seamless integration with the Spring ecosystem, comprehensive OAuth2 support, and cloud-native capabilities make it ideal for microservices architectures.

Historically derived from Acegi Security, early versions suffered from verbose XML configuration. Modern Spring Boot auto-configuration has dramatically simplified setup—often requiring only a single dependency to secure all endpoints automatically.

Architecture Components

Spring Security maintains a clean separation between authentication and authorization concerns, enabling flexible integration with external identity providers.

Authentication Flow

The security filter chain orchestrates the authentication process through specialized components:

  • SecurityContextPersistenceFilter: Bookends each request, retrieving and storing security context from the configured repository
  • UsernamePasswordAuthenticationFilter: Processes credential submissions from login forms, delegating to configurable success/failure handlers
  • ExceptionTranslationFilter: Captures and translates security exceptions, redirecting to authentication entry points when needed
  • FilterSecurityInterceptor: Enforces authorization decisions for protected resources using configured access decision managers

The authentication sequence proceeds as follows:

  1. User credentials are captured and encapsulated in an Authentication token
  2. The token is submitted to AuthenticationManager for validation
  3. Upon success, a populated Authentication object (containing granted authorities) is returned
  4. The security context is updated with the authenticated principal

The AuthenticationManager delegates to AuthenticationProvider implementations. For database-backed authentication, DaoAuthenticationProvider collaborates with a UserDetailsService to load user information.

Authorization Flow

Post-authentication, authorization decisions involve three key interfaces:

  1. FilterSecurityInterceptor intercepts requests to protected resources
  2. SecurityMetadataSource provides required permissions for the requested resource
  3. AccessDecisionManager evaluates the user's authorities against required permissions

The AccessDecisionManager consults multiple AccessDecisionVoter instances, similar to how ProviderManager aggregates AuthenticationProvider instances. Voters examine user authorities and required ConfigAttribute objects to cast approval, denial, or abstention votes.

Project Initialization

Create a Spring Boot application with the following dependencies:

<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-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
    </dependency>
</dependencies>

Define a basic controller:

@RestController
@RequestMapping("/api/greeting")
public class GreetingController {
    @GetMapping
    public String greet() {
        return "Secure Hello World";
    }
}

Upon startup, Spring Security generates a default password and secures all endpoints. Accessing /api/greeting redirects to a login page where user and the generated password grant access.

Database-Backed User Store

Replace the in-memory user store with database authentication.

User Entity and Data Access

Create the user entity and repository:

@TableName("app_user")
@Data
public class AppUser {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String password;
    private String email;
    private Boolean enabled;
}

@Mapper
public interface UserRepository extends BaseMapper<AppUser> {
    @Select("SELECT * FROM app_user WHERE username = #{username}")
    AppUser findByUsername(@Param("username") String username);
}

Custom UserDetailsService

Implement a service to load user details from the database:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("Account not found: " + username);
        }
        
        SecurityUserPrincipal principal = new SecurityUserPrincipal();
        principal.setUser(user);
        return principal;
    }
}

Security User Principal

@Data
public class SecurityUserPrincipal implements UserDetails {
    private AppUser user;
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }
    
    @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 true;
    }
    
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    
    @Override
    public boolean isEnabled() {
        return user.getEnabled();
    }
}

Password Encoding Configuration

Configure BCrypt password hashing:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        System.out.println(encoder.encode("plaintext_password"));
    }
}

Custom Authentication Endpoint

Replace the default form login with a RESTful authentication endpoint.

Authentication Controller

@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
    @Autowired
    private AuthenticationService authService;
    
    @PostMapping("/signin")
    public ApiResponse authenticate(@RequestBody CredentialDto credentials) {
        return authService.performAuthentication(credentials);
    }
}

Authentication Service

@Service
public class AuthenticationService {
    @Autowired
    private AuthenticationManager authManager;
    
    public ApiResponse performAuthentication(CredentialDto credentials) {
        UsernamePasswordAuthenticationToken authToken = 
            new UsernamePasswordAuthenticationToken(
                credentials.getUsername(), 
                credentials.getPassword()
            );
        
        Authentication authentication = authManager.authenticate(authToken);
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new BadCredentialsException("Invalid credentials");
        }
        
        SecurityUserPrincipal principal = (SecurityUserPrincipal) authentication.getPrincipal();
        String userId = principal.getUser().getId().toString();
        
        return ApiResponse.success("Authentication successful", userId);
    }
}

Security Configuration Update

@Bean
public AuthenticationManager authenticationManager(
        UserDetailsService userDetailsService, 
        PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService);
    provider.setPasswordEncoder(passwordEncoder);
    return new ProviderManager(provider);
}

JWT Implementation

Token Generation

Create a utility for JWT operations:

@Component
public class JwtTokenProvider {
    private final String secretKey = "your-256-bit-secret-key-minimum-32-chars";
    private final long validityMs = 3600000; // 1 hour
    
    public String generateToken(String userId) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + validityMs);
        
        return Jwts.builder()
            .setSubject(userId)
            .setIssuedAt(now)
            .setExpiration(expiry)
            .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
            .compact();
    }
    
    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
}

Token-Based Authentication Response

Modify the authentication service to return JWT tokens:

@Service
public class AuthenticationService {
    @Autowired
    private AuthenticationManager authManager;
    @Autowired
    private JwtTokenProvider tokenProvider;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public ApiResponse performAuthentication(CredentialDto credentials) {
        Authentication authentication = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                credentials.getUsername(), 
                credentials.getPassword()
            )
        );
        
        SecurityUserPrincipal principal = (SecurityUserPrincipal) authentication.getPrincipal();
        String userId = principal.getUser().getId().toString();
        
        String jwt = tokenProvider.generateToken(userId);
        
        redisTemplate.opsForValue().set(
            "auth:" + userId, 
            principal, 
            30, 
            TimeUnit.MINUTES
        );
        
        Map<String, String> tokenMap = new HashMap<>();
        tokenMap.put("accessToken", jwt);
        
        return ApiResponse.success("Login successful", tokenMap);
    }
}

Redis Integration for Session Management

Disable Session Creation

Configure stateless session management:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session -> 
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**", "/public/**").permitAll()
            .anyRequest().authenticated()
        );
    return http.build();
}

Token Validation Filter

Create a filter to validate JWT tokens on each request:

@Component
public class JwtTokenValidationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtTokenProvider tokenProvider;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Override
    protected void doFilterInternal(
            HttpServletRequest request, 
            HttpServletResponse response, 
            FilterChain chain) throws ServletException, IOException {
        
        String token = extractToken(request);
        
        if (StringUtils.hasText(token)) {
            try {
                Claims claims = tokenProvider.parseToken(token);
                String userId = claims.getSubject();
                
                SecurityUserPrincipal principal = (SecurityUserPrincipal) 
                    redisTemplate.opsForValue().get("auth:" + userId);
                
                if (principal == null) {
                    throw new AuthenticationCredentialsNotFoundException("Session expired");
                }
                
                UsernamePasswordAuthenticationToken authToken = 
                    new UsernamePasswordAuthenticationToken(
                        principal, 
                        null, 
                        principal.getAuthorities()
                    );
                
                SecurityContextHolder.getContext().setAuthentication(authToken);
            } catch (Exception e) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }
        
        chain.doFilter(request, response);
    }
    
    private String extractToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

Register the Filter

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session -> 
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**", "/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(
            jwtTokenValidationFilter, 
            UsernamePasswordAuthenticationFilter.class
        );
    return http.build();
}

Logout Implementation

Implement token revocation on logout:

@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @PostMapping("/logout")
    public ApiResponse logout(HttpServletRequest request) {
        String token = extractToken(request);
        Claims claims = tokenProvider.parseToken(token);
        String userId = claims.getSubject();
        
        redisTemplate.delete("auth:" + userId);
        SecurityContextHolder.clearContext();
        
        return ApiResponse.success("Logout successful", null);
    }
}

Custom Exception Handling

Configure authentication and access denial handlers:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .exceptionHandling(exceptions -> exceptions
            .authenticationEntryPoint((request, response, authException) -> {
                ApiResponse errorResponse = ApiResponse.error(
                    HttpStatus.UNAUTHORIZED.value(), 
                    "Authentication required"
                );
                writeJsonResponse(response, errorResponse);
            })
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                ApiResponse errorResponse = ApiResponse.error(
                    HttpStatus.FORBIDDEN.value(), 
                    "Insufficient privileges"
                );
                writeJsonResponse(response, errorResponse);
            })
        );
    return http.build();
}

private void writeJsonResponse(HttpServletResponse response, ApiResponse apiResponse) 
        throws IOException {
    response.setContentType("application/json");
    response.setCharacterEncoding("UTF-8");
    response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
}

Authorization Implementation

Permission Models

Two primary approaches exist:

  • Role-Based: Users → Roles → Resources
  • Permission-Based: Users → Permissions → Resources

Spring Security treats both similarly, though roles typically receive an automatic ROLE_ prefix.

Database Schema

Define roles and permissions entities:

@Data
@TableName("user_role")
public class UserRole {
    @TableId
    private Integer id;
    private String roleName;
}

@Data
@TableName("user_permission")
public class UserPermission {
    @TableId
    private Integer id;
    private String permissionCode;
    private String description;
}

Enhanced User Principal

@Data
public class SecurityUserPrincipal implements UserDetails {
    private AppUser user;
    private List<UserRole> roles = new ArrayList<>();
    private List<UserPermission> permissions = new ArrayList<>();
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Set<SimpleGrantedAuthority> authorities = new HashSet<>();
        
        roles.forEach(role -> 
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName()))
        );
        
        permissions.forEach(permission -> 
            authorities.add(new SimpleGrantedAuthority(permission.getPermissionCode()))
        );
        
        return authorities;
    }
    
    // Other UserDetails methods...
}

Permission Loading Service

@Service
public class PermissionService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private RoleRepository roleRepository;
    @Autowired
    private PermissionRepository permissionRepository;
    
    public List<UserPermission> loadPermissionsByUsername(String username) {
        AppUser user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found");
        }
        
        List<Integer> roleIds = parseIdList(user.getRoleIds());
        List<UserRole> roles = roleRepository.selectBatchIds(roleIds);
        
        Set<Integer> permissionIds = new HashSet<>();
        roles.forEach(role -> 
            permissionIds.addAll(parseIdList(role.getPermissionIds()))
        );
        
        return permissionRepository.selectBatchIds(new ArrayList<>(permissionIds));
    }
    
    private List<Integer> parseIdList(String idString) {
        return Arrays.stream(idString.split(","))
            .map(String::trim)
            .map(Integer::parseInt)
            .collect(Collectors.toList());
    }
}

Method-Level Security

Enable method security annotations:

@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfiguration {}

Apply permissions to controller methods:

@RestController
@RequestMapping("/api/admin")
public class AdminController {
    
    @GetMapping("/dashboard")
    @PreAuthorize("hasAuthority('admin:read')")
    public String viewDashboard() {
        return "Admin Dashboard";
    }
    
    @PostMapping("/users")
    @PreAuthorize("hasAuthority('admin:create')")
    public String createUser(@RequestBody UserDto user) {
        return "User created";
    }
    
    @PutMapping("/users/{id}")
    @PreAuthorize("hasAnyAuthority({'admin:update', 'user:self-update'})")
    public String updateUser(@PathVariable Long id, @RequestBody UserDto user) {
        return "User updated";
    }
}

Frontend Axios Integration

Configure request and response interceptors:

// Request interceptor to attach token
axios.interceptors.request.use(
    config => {
        const token = sessionStorage.getItem('accessToken');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    error => Promise.reject(error)
);

// Response interceptor to handle errors
axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response?.status === 403) {
            alert('Access denied: ' + error.response.data.message);
            window.location.href = '/login.html';
        }
        if (error.response?.status === 401) {
            alert('Session expired. Please login again.');
            sessionStorage.removeItem('accessToken');
            window.location.href = '/login.html';
        }
        return Promise.reject(error);
    }
);

// Store token after successful login
function handleLoginSuccess(response) {
    const token = response.data.data.accessToken;
    sessionStorage.setItem('accessToken', token);
    window.location.href = '/dashboard.html';
}

Tags: spring-security JWT spring-boot Redis Authentication

Posted on Sat, 25 Jul 2026 16:21:40 +0000 by THW