Spring Security — Authentication and Authorization
Secure Spring Boot applications with Spring Security — HTTP security config, user details, password encoding, role-based authorization, and method-level security.
Spring Security is the standard security framework for Spring Boot. It integrates deeply with the framework — once configured, security rules apply automatically to every request without any changes to your controllers. Adding the starter dependency immediately locks down all endpoints, so you configure explicitly what should be public and what requires authentication.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Adding this dependency immediately activates three defaults:
- Requires authentication for all endpoints
- Generates a random password printed to the console on startup
- Enables CSRF protection (fine for web apps, disable for stateless REST APIs)
Security Configuration
The SecurityFilterChain bean is where you define the security rules for your application. It replaces the old WebSecurityConfigurerAdapter approach. Each http method call configures one aspect of the security chain — CSRF handling, session policy, authorization rules, and the authentication provider.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize and @PostAuthorize on methods
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public SecurityConfig(UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // stateless REST APIs don't need CSRF protection
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // login/register are public
.requestMatchers("/api/admin/**").hasRole("ADMIN") // admin-only section
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() // public reads
.anyRequest().authenticated() // everything else requires login
)
.authenticationProvider(authenticationProvider());
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
// DaoAuthenticationProvider loads users from your UserDetailsService
// and verifies passwords using the PasswordEncoder
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor 12 — strong enough for production
}
}
UserDetailsService — Loading Users
Spring Security calls loadUserByUsername during every authentication attempt. It’s the bridge between your user database and Spring Security’s user model. Return a UserDetails object that contains the username, hashed password, and granted authorities (roles).
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// Spring Security calls this with whatever the client sent as the username field
return userRepository.findByEmail(email)
.map(user -> org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPasswordHash()) // already hashed — Spring verifies it
.roles(user.getRole().name()) // becomes ROLE_USER, ROLE_ADMIN, etc.
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
}
}
Password Encoding
Storing plain-text passwords is a critical security vulnerability. If your database is ever compromised, all user passwords are exposed. BCrypt is a deliberately slow hashing algorithm — the cost factor controls how long each hash takes, making brute-force attacks impractical even with the hash in hand.
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public void register(RegisterRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new DuplicateResourceException("Email already registered");
}
User user = new User(
req.name(),
req.email(),
passwordEncoder.encode(req.password()), // hash the plain-text password before storing
UserRole.USER
);
userRepository.save(user);
}
public boolean checkPassword(String rawPassword, String storedHash) {
// matches() hashes the rawPassword and compares — never compare hashes directly
return passwordEncoder.matches(rawPassword, storedHash);
}
}
Role-Based Authorization
URL-Level Rules
URL-level rules are declared in the SecurityFilterChain and apply before any controller code runs. They’re good for coarse-grained access control — entire sections of the API that are restricted to a role.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // public
.requestMatchers("/api/admin/**").hasRole("ADMIN") // admin only
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MODERATOR")
.requestMatchers(HttpMethod.GET, "/api/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
Method-Level Security
Method-level security with @PreAuthorize gives you fine-grained control. You can reference the authenticated user directly in the expression, which makes it easy to enforce ownership rules — for example, allowing users to see their own profile but not others’.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
@PreAuthorize("hasRole('ADMIN')") // only admins can list all users
public List<UserResponse> getAllUsers() { ... }
@GetMapping("/{id}")
// admins can see anyone; users can only see themselves
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public UserResponse getUserById(@PathVariable Long id) { ... }
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(@PathVariable Long id) { ... }
}
Getting the Current User
Spring Security stores the authenticated user in SecurityContextHolder for the duration of the request. You can access it directly from a controller parameter or call the static method from anywhere in the request thread.
// Inject Authentication directly — Spring binds it automatically
@GetMapping("/me")
public UserResponse getCurrentUser(Authentication authentication) {
String email = authentication.getName(); // returns the username from UserDetails
return userService.findByEmail(email);
}
// Or access from a service via the SecurityContextHolder
public static String getCurrentUserEmail() {
return SecurityContextHolder.getContext()
.getAuthentication()
.getName();
}
HTTPS and Security Headers
Security headers are a low-effort, high-value hardening measure. HSTS prevents protocol downgrade attacks. frameOptions(deny) prevents clickjacking. contentTypeOptions prevents MIME-type sniffing attacks. These should be enabled in any production deployment.
http
.requiresChannel(channel -> channel.anyRequest().requiresSecure()) // redirect HTTP → HTTPS
.headers(headers -> headers
.frameOptions(frame -> frame.deny()) // prevents iframe embedding (clickjacking)
.contentTypeOptions(Customizer.withDefaults()) // prevents MIME sniffing
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000) // browsers remember HTTPS-only for 1 year
.includeSubDomains(true))
);
Test Endpoints With Security
Spring Security’s test support lets you simulate authenticated requests without running a real login flow. @WithMockUser injects a fake authenticated user into the security context for the duration of the test.
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER")
void getUser_authenticated_returns200() throws Exception {
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk());
}
@Test
void getUser_unauthenticated_returns401() throws Exception {
// No @WithMockUser — request has no authentication context
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "ADMIN")
void deleteUser_asAdmin_returns204() throws Exception {
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(roles = "USER")
void deleteUser_asUser_returns403() throws Exception {
// USER role doesn't have delete permission — should be rejected
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isForbidden());
}
}