Spring Security Complete Notes
Table of Contents​
- Introduction to Spring Security
- Core Architecture
- Authentication vs Authorization
- Password Encoding with BCrypt
- JWT (JSON Web Tokens)
- Refresh Tokens
- HttpOnly Cookies
- Role-Based Access Control (RBAC)
- Security Configuration
- Custom Authentication Provider
- Method-Level Security
- CSRF Protection
- CORS Configuration
- Session Management
- OAuth2 Integration
- Security Best Practices
Introduction to Spring Security​
Spring Security is a powerful and highly customizable authentication and access-control framework for Spring applications. It provides comprehensive security services for Java EE-based enterprise software applications.
Key Features:​
- Authentication: Verifying the identity of users
- Authorization: Controlling access to resources
- Protection against attacks: CSRF, session fixation, clickjacking, etc.
- Servlet API integration: Works seamlessly with Spring Boot
- Password encoding: Built-in support for various encoding algorithms
- Remember-me authentication: Persistent login functionality
Core Architecture​
Security Filter Chain​
Spring Security uses a chain of filters to process security-related tasks:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout.permitAll());
return http.build();
}
}
Key Components:​
- SecurityContext: Holds security information about current user
- Authentication: Represents user credentials and authorities
- AuthenticationManager: Processes authentication requests
- UserDetailsService: Loads user-specific data
- PasswordEncoder: Encodes passwords securely
Authentication vs Authorization​
Authentication​
Process of verifying who the user is.
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.build();
}
}
Authorization​
Process of determining what the authenticated user is allowed to do.
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/users")
public List<User> getAllUsers() {
return userService.getAllUsers();
}
@PreAuthorize("hasRole('USER') and #userId == authentication.principal.id")
@GetMapping("/user/{userId}/profile")
public UserProfile getUserProfile(@PathVariable Long userId) {
return userService.getUserProfile(userId);
}
Password Encoding with BCrypt​
BCrypt is a password hashing function designed to be slow and computationally expensive, making it resistant to brute-force attacks.
Theory:​
- Salt: Random data added to password before hashing
- Cost Factor: Controls how slow the algorithm runs
- One-way function: Cannot be reversed to get original password
Implementation:​
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // Strength of 12
}
}
@Service
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
public void createUser(String username, String rawPassword) {
String encodedPassword = passwordEncoder.encode(rawPassword);
User user = new User(username, encodedPassword);
userRepository.save(user);
}
public boolean verifyPassword(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
}
BCrypt Strengths:​
- 10: 2^10 = 1,024 rounds (fast, for testing)
- 12: 2^12 = 4,096 rounds (recommended for production)
- 15: 2^15 = 32,768 rounds (very secure, slower)
JWT (JSON Web Tokens)​
JWT is a compact, URL-safe means of representing claims between two parties.
Structure:​
Header.Payload.Signature
Header:​
{
"alg": "HS256",
"typ": "JWT"
}
Payload:​
{
"sub": "user123",
"name": "John Doe",
"roles": ["USER", "ADMIN"],
"exp": 1735689600,
"iat": 1735686000
}
JWT Implementation:​
@Component
public class JwtUtil {
private String secret = "mySecretKey";
private int jwtExpiration = 86400000; // 24 hours
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
claims.put("roles", authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
}
JWT Filter:​
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
final String requestTokenHeader = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtUtil.getUsernameFromToken(jwtToken);
} catch (IllegalArgumentException e) {
System.out.println("Unable to get JWT Token");
} catch (ExpiredJwtException e) {
System.out.println("JWT Token has expired");
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}
Refresh Tokens​
Refresh tokens provide a secure way to obtain new access tokens without requiring users to re-authenticate.
Theory:​
- Access Token: Short-lived (15-30 minutes)
- Refresh Token: Long-lived (days/weeks), stored securely
- Rotation: Generate new refresh token with each use
Implementation:​
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private Instant expiryDate;
@OneToOne
@JoinColumn(name = "user_id", referencedColumnName = "id")
private User user;
// constructors, getters, setters
}
@Service
public class RefreshTokenService {
@Value("${app.jwtRefreshExpirationMs}")
private Long refreshTokenDurationMs;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
public RefreshToken createRefreshToken(Long userId) {
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUser(userRepository.findById(userId).get());
refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));
refreshToken.setToken(UUID.randomUUID().toString());
refreshToken = refreshTokenRepository.save(refreshToken);
return refreshToken;
}
public RefreshToken verifyExpiration(RefreshToken token) {
if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
refreshTokenRepository.delete(token);
throw new TokenRefreshException(token.getToken(),
"Refresh token was expired. Please make a new signin request");
}
return token;
}
@Transactional
public int deleteByUserId(Long userId) {
return refreshTokenRepository.deleteByUser(userRepository.findById(userId).get());
}
}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/refreshtoken")
public ResponseEntity<?> refreshtoken(@Valid @RequestBody TokenRefreshRequest request) {
String requestRefreshToken = request.getRefreshToken();
return refreshTokenService.findByToken(requestRefreshToken)
.map(refreshTokenService::verifyExpiration)
.map(RefreshToken::getUser)
.map(user -> {
String token = jwtUtils.generateTokenFromUsername(user.getUsername());
return ResponseEntity.ok(new TokenRefreshResponse(token, requestRefreshToken));
})
.orElseThrow(() -> new TokenRefreshException(requestRefreshToken,
"Refresh token is not in database!"));
}
}
HttpOnly Cookies​
HttpOnly cookies provide enhanced security by preventing JavaScript access, making them ideal for storing sensitive tokens.
Theory:​
- HttpOnly Flag: Prevents XSS attacks by blocking JavaScript access
- Secure Flag: Ensures transmission only over HTTPS
- SameSite: Controls cross-site request behavior
Implementation:​
@Service
public class CookieService {
public ResponseCookie createAccessTokenCookie(String token, Duration duration) {
return ResponseCookie.from("accessToken", token)
.maxAge(duration)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/")
.build();
}
public ResponseCookie createRefreshTokenCookie(String token, Duration duration) {
return ResponseCookie.from("refreshToken", token)
.maxAge(duration)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/api/auth")
.build();
}
public ResponseCookie deleteAccessTokenCookie() {
return ResponseCookie.from("accessToken", "")
.maxAge(0)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/")
.build();
}
}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest,
HttpServletResponse response) {
Authentication authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
String accessToken = jwtUtils.generateJwtToken(userDetails);
RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getId());
ResponseCookie accessTokenCookie = cookieService
.createAccessTokenCookie(accessToken, Duration.ofMinutes(15));
ResponseCookie refreshTokenCookie = cookieService
.createRefreshTokenCookie(refreshToken.getToken(), Duration.ofDays(7));
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
return ResponseEntity.ok(new MessageResponse("User signed in successfully!"));
}
@PostMapping("/signout")
public ResponseEntity<?> logoutUser(HttpServletResponse response) {
SecurityContextHolder.getContext().setAuthentication(null);
ResponseCookie accessTokenCookie = cookieService.deleteAccessTokenCookie();
ResponseCookie refreshTokenCookie = cookieService.deleteRefreshTokenCookie();
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
return ResponseEntity.ok(new MessageResponse("You've been signed out!"));
}
}
Cookie Filter:​
@Component
public class CookieAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String jwt = getJwtFromCookies(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
String username = jwtUtils.getUserNameFromJwtToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
Role-Based Access Control (RBAC)​
RBAC is a method of regulating access based on the roles of individual users within an organization.
Entity Design:​
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
private String email;
private boolean enabled = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
// constructors, getters, setters
}
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private RoleName name;
@ManyToMany(mappedBy = "roles")
private Set<User> users = new HashSet<>();
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
// constructors, getters, setters
}
@Entity
@Table(name = "permissions")
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
@ManyToMany(mappedBy = "permissions")
private Set<Role> roles = new HashSet<>();
// constructors, getters, setters
}
public enum RoleName {
ROLE_USER,
ROLE_ADMIN,
ROLE_MODERATOR
}
Method-Level Security:​
@RestController
@RequestMapping("/api")
@PreAuthorize("isAuthenticated()")
public class UserController {
@GetMapping("/user/profile")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<UserProfile> getUserProfile(Authentication authentication) {
return ResponseEntity.ok(userService.getUserProfile(authentication.getName()));
}
@GetMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<User>> getAllUsers() {
return ResponseEntity.ok(userService.getAllUsers());
}
@PostMapping("/admin/users/{userId}/roles")
@PreAuthorize("hasRole('ADMIN') and hasAuthority('USER_MANAGEMENT')")
public ResponseEntity<?> assignRole(@PathVariable Long userId, @RequestBody RoleRequest request) {
userService.assignRole(userId, request.getRoleName());
return ResponseEntity.ok(new MessageResponse("Role assigned successfully"));
}
@DeleteMapping("/moderator/posts/{postId}")
@PreAuthorize("hasRole('MODERATOR') or (hasRole('USER') and @postService.isOwner(#postId, authentication.name))")
public ResponseEntity<?> deletePost(@PathVariable Long postId) {
postService.deletePost(postId);
return ResponseEntity.ok(new MessageResponse("Post deleted successfully"));
}
}
Custom Permission Evaluator:​
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private UserService userService;
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
if ((auth == null) || (targetDomainObject == null) || !(permission instanceof String)) {
return false;
}
String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();
return hasPrivilege(auth, targetType, permission.toString().toUpperCase());
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
if ((auth == null) || (targetType == null) || !(permission instanceof String)) {
return false;
}
return hasPrivilege(auth, targetType.toUpperCase(), permission.toString().toUpperCase());
}
private boolean hasPrivilege(Authentication auth, String resourceType, String permission) {
UserDetailsImpl user = (UserDetailsImpl) auth.getPrincipal();
Set<String> userPermissions = userService.getUserPermissions(user.getUsername());
String requiredPermission = resourceType + "_" + permission;
return userPermissions.contains(requiredPermission) || userPermissions.contains("ALL_PERMISSIONS");
}
}
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Autowired
private CustomPermissionEvaluator permissionEvaluator;
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler =
new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(permissionEvaluator);
return expressionHandler;
}
}
Security Configuration​
Complete Security Configuration:​
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private AuthEntryPointJwt unauthorizedHandler;
@Bean
public JwtAuthenticationFilter authenticationJwtTokenFilter() {
return new JwtAuthenticationFilter();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/moderator/**").hasRole("MODERATOR")
.anyRequest().authenticated()
);
http.authenticationProvider(authenticationProvider());
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Custom Authentication Provider​
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (passwordEncoder.matches(password, userDetails.getPassword())) {
// Additional custom validation logic here
if (isAccountLocked(username)) {
throw new AccountStatusException("Account is locked") {};
}
if (requiresTwoFactorAuth(username) && !isTwoFactorValid(authentication)) {
throw new BadCredentialsException("Two-factor authentication required");
}
return new UsernamePasswordAuthenticationToken(
userDetails, password, userDetails.getAuthorities());
} else {
throw new BadCredentialsException("Authentication failed");
}
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
private boolean isAccountLocked(String username) {
// Custom logic to check if account is locked
return false;
}
private boolean requiresTwoFactorAuth(String username) {
// Custom logic to check if 2FA is required
return false;
}
private boolean isTwoFactorValid(Authentication authentication) {
// Custom logic to validate 2FA
return true;
}
}
Method-Level Security​
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN') or hasRole('USER')")
public List<Document> getAllDocuments() {
return documentRepository.findAll();
}
@PreAuthorize("#username == authentication.name or hasRole('ADMIN')")
public List<Document> getUserDocuments(String username) {
return documentRepository.findByUsername(username);
}
@PostAuthorize("returnObject.owner == authentication.name or hasRole('ADMIN')")
public Document getDocument(Long id) {
return documentRepository.findById(id).orElse(null);
}
@PreFilter("filterObject.owner == authentication.name or hasRole('ADMIN')")
public List<Document> processDocuments(List<Document> documents) {
// Process only documents that pass the pre-filter
return documents.stream()
.map(this::processDocument)
.collect(Collectors.toList());
}
@PostFilter("filterObject.confidential == false or hasRole('ADMIN')")
public List<Document> getFilteredDocuments() {
return documentRepository.findAll();
}
}
CSRF Protection​
Cross-Site Request Forgery (CSRF) protection prevents unauthorized actions on behalf of authenticated users.
@Configuration
public class CsrfSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/public/**")
.csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
)
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
return http.build();
}
}
public class CsrfCookieFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
// Ensure the token is available to the client
csrfToken.getToken();
}
filterChain.doFilter(request, response);
}
}
CORS Configuration​
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setExposedHeaders(Arrays.asList("Authorization", "X-Total-Count"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
Session Management​
@Configuration
public class SessionConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry())
.and()
.sessionFixation().migrateSession()
.invalidSessionUrl("/login?expired")
)
.rememberMe(remember -> remember
.key("uniqueAndSecret")
.tokenValiditySeconds(86400) // 24 hours
.userDetailsService(userDetailsService)
);
return http.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
}
OAuth2 Integration​
@Configuration
@EnableOAuth2Client
public class OAuth2Config {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo
.userService(this.oauth2UserService())
)
.successHandler(oauth2AuthenticationSuccessHandler())
.failureHandler(oauth2AuthenticationFailureHandler())
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
return new CustomOAuth2UserService();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("https://your-auth-server.com/.well-known/jwks.json").build();
}
@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<String> authorities = jwt.getClaimAsStringList("authorities");
return authorities.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
});
return jwtConverter;
}
}
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Autowired
private UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oauth2User = super.loadUser(userRequest);
try {
return processOAuth2User(userRequest, oauth2User);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oauth2User) {
OAuth2UserInfo oauth2UserInfo = OAuth2UserInfoFactory
.getOAuth2UserInfo(userRequest.getClientRegistration().getRegistrationId(), oauth2User.getAttributes());
if (StringUtils.isEmpty(oauth2UserInfo.getEmail())) {
throw new OAuth2AuthenticationProcessingException("Email not found from OAuth2 provider");
}
Optional<User> userOptional = userRepository.findByEmail(oauth2UserInfo.getEmail());
User user;
if (userOptional.isPresent()) {
user = userOptional.get();
if (!user.getProvider().equals(AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId()))) {
throw new OAuth2AuthenticationProcessingException("Looks like you're signed up with " +
user.getProvider() + " account. Please use your " + user.getProvider() +
" account to login.");
}
user = updateExistingUser(user, oauth2UserInfo);
} else {
user = registerNewUser(userRequest, oauth2UserInfo);
}
return UserPrincipal.create(user, oauth2User.getAttributes());
}
private User registerNewUser(OAuth2UserRequest userRequest, OAuth2UserInfo oauth2UserInfo) {
User user = new User();
user.setProvider(AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId()));
user.setProviderId(oauth2UserInfo.getId());
user.setName(oauth2UserInfo.getName());
user.setEmail(oauth2UserInfo.getEmail());
user.setImageUrl(oauth2UserInfo.getImageUrl());
return userRepository.save(user);
}
private User updateExistingUser(User existingUser, OAuth2UserInfo oauth2UserInfo) {
existingUser.setName(oauth2UserInfo.getName());
existingUser.setImageUrl(oauth2UserInfo.getImageUrl());
return userRepository.save(existingUser);
}
}
Security Best Practices​
1. Password Security​
@Configuration
public class PasswordPolicyConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// Use BCrypt with strength 12 for production
return new BCryptPasswordEncoder(12);
}
@Bean
public PasswordValidator passwordValidator() {
return new PasswordValidator(Arrays.asList(
new LengthRule(8, 128),
new CharacterRule(EnglishCharacterData.UpperCase, 1),
new CharacterRule(EnglishCharacterData.LowerCase, 1),
new CharacterRule(EnglishCharacterData.Digit, 1),
new CharacterRule(EnglishCharacterData.Special, 1),
new WhitespaceRule()
));
}
}
@Service
public class PasswordService {
@Autowired
private PasswordValidator passwordValidator;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private PasswordHistoryRepository passwordHistoryRepository;
public void validateAndChangePassword(String username, String newPassword) {
// Validate password strength
RuleResult result = passwordValidator.validate(new PasswordData(newPassword));
if (!result.isValid()) {
throw new WeakPasswordException(passwordValidator.getMessages(result));
}
// Check password history (prevent reuse of last 5 passwords)
List<String> passwordHistory = passwordHistoryRepository.getLastPasswords(username, 5);
for (String oldPassword : passwordHistory) {
if (passwordEncoder.matches(newPassword, oldPassword)) {
throw new PasswordReuseException("Cannot reuse recent passwords");
}
}
// Save new password
String encodedPassword = passwordEncoder.encode(newPassword);
userService.updatePassword(username, encodedPassword);
// Store in password history
passwordHistoryRepository.save(new PasswordHistory(username, encodedPassword));
}
}
2. Account Lockout and Rate Limiting​
@Service
public class AccountLockoutService {
private static final int MAX_ATTEMPTS = 5;
private static final long LOCKOUT_DURATION_MS = 30 * 60 * 1000; // 30 minutes
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void recordFailedAttempt(String username) {
String key = "failed_attempts:" + username;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
if (attempts == null) {
attempts = 0;
}
attempts++;
redisTemplate.opsForValue().set(key, attempts, Duration.ofMinutes(30));
if (attempts >= MAX_ATTEMPTS) {
lockAccount(username);
}
}
public void clearFailedAttempts(String username) {
redisTemplate.delete("failed_attempts:" + username);
}
public boolean isAccountLocked(String username) {
String lockKey = "account_locked:" + username;
return redisTemplate.hasKey(lockKey);
}
private void lockAccount(String username) {
String lockKey = "account_locked:" + username;
redisTemplate.opsForValue().set(lockKey, "locked", Duration.ofMillis(LOCKOUT_DURATION_MS));
}
public int getFailedAttempts(String username) {
String key = "failed_attempts:" + username;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
return attempts != null ? attempts : 0;
}
}
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final int MAX_REQUESTS_PER_MINUTE = 60;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String clientIp = getClientIP(request);
String key = "rate_limit:" + clientIp;
Integer requests = (Integer) redisTemplate.opsForValue().get(key);
if (requests == null) {
requests = 0;
}
if (requests >= MAX_REQUESTS_PER_MINUTE) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
return;
}
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofMinutes(1));
filterChain.doFilter(request, response);
}
private String getClientIP(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
}
3. Audit Logging​
@Entity
@Table(name = "security_audit_log")
public class SecurityAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String action;
private String details;
private String ipAddress;
private String userAgent;
@CreationTimestamp
private LocalDateTime timestamp;
private boolean success;
// constructors, getters, setters
}
@Service
public class SecurityAuditService {
@Autowired
private SecurityAuditLogRepository auditLogRepository;
@Async
public void logSecurityEvent(String username, String action, String details,
HttpServletRequest request, boolean success) {
SecurityAuditLog log = new SecurityAuditLog();
log.setUsername(username);
log.setAction(action);
log.setDetails(details);
log.setIpAddress(getClientIP(request));
log.setUserAgent(request.getHeader("User-Agent"));
log.setSuccess(success);
auditLogRepository.save(log);
}
private String getClientIP(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
}
@EventListener
@Component
public class SecurityEventListener {
@Autowired
private SecurityAuditService auditService;
@EventListener
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
HttpServletRequest request = getCurrentRequest();
auditService.logSecurityEvent(username, "LOGIN_SUCCESS",
"User successfully authenticated", request, true);
}
@EventListener
public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = event.getAuthentication().getName();
HttpServletRequest request = getCurrentRequest();
auditService.logSecurityEvent(username, "LOGIN_FAILURE",
"Authentication failed: " + event.getException().getMessage(), request, false);
}
private HttpServletRequest getCurrentRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
return null;
}
}
4. Two-Factor Authentication (2FA)​
@Service
public class TwoFactorAuthService {
@Autowired
private UserRepository userRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public String generateSecretKey() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[20];
random.nextBytes(bytes);
return Base32.encode(bytes);
}
public String generateQRCodeImageUri(String username, String secretKey) {
return String.format(
"otpauth://totp/%s?secret=%s&issuer=MyApp",
username, secretKey
);
}
public boolean validateTOTP(String username, String code) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UserNotFoundException("User not found"));
if (!user.isTwoFactorEnabled()) {
return true; // 2FA not required for this user
}
String secretKey = user.getTwoFactorSecret();
long timeStep = System.currentTimeMillis() / 30000;
// Check current time step and previous/next for clock skew
for (int i = -1; i <= 1; i++) {
String expectedCode = generateTOTP(secretKey, timeStep + i);
if (code.equals(expectedCode)) {
// Check if this code was already used (prevent replay attacks)
String usedCodeKey = "used_totp:" + username + ":" + code;
if (!redisTemplate.hasKey(usedCodeKey)) {
redisTemplate.opsForValue().set(usedCodeKey, "used", Duration.ofMinutes(2));
return true;
}
}
}
return false;
}
private String generateTOTP(String secretKey, long timeStep) {
byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
byte[] key = Base32.decode(secretKey);
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0x0f;
int code = ((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
return String.format("%06d", code % 1000000);
} catch (Exception e) {
throw new RuntimeException("Error generating TOTP", e);
}
}
}
5. Content Security Policy (CSP)​
@Configuration
public class SecurityHeadersConfig {
@Bean
public FilterRegistrationBean<SecurityHeadersFilter> securityHeadersFilter() {
FilterRegistrationBean<SecurityHeadersFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new SecurityHeadersFilter());
registration.addUrlPatterns("/*");
return registration;
}
}
public class SecurityHeadersFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
// Content Security Policy
httpResponse.setHeader("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self' https:; " +
"connect-src 'self'; " +
"frame-ancestors 'none';");
// X-Frame-Options
httpResponse.setHeader("X-Frame-Options", "DENY");
// X-Content-Type-Options
httpResponse.setHeader("X-Content-Type-Options", "nosniff");
// X-XSS-Protection
httpResponse.setHeader("X-XSS-Protection", "1; mode=block");
// Strict-Transport-Security (HSTS)
httpResponse.setHeader("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload");
// Referrer Policy
httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
// Feature Policy
httpResponse.setHeader("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()");
chain.doFilter(request, response);
}
}
Common Security Configurations​
Production Application Properties​
# Server configuration
server.port=8443
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=password
server.ssl.key-store-type=PKCS12
server.ssl.key-alias=myapp
# JWT Configuration
app.jwtSecret=mySecretKey
app.jwtExpirationMs=900000
app.jwtRefreshExpirationMs=86400000
# Database
spring.datasource.url=jdbc:postgresql://localhost:5432/myapp
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
# Redis (for session/rate limiting)
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=${REDIS_PASSWORD}
# Logging
logging.level.org.springframework.security=DEBUG
logging.level.com.myapp.security=DEBUG
# Security headers
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
Testing Security Configuration​
@SpringBootTest
@AutoConfigureMockMvc
class SecurityConfigTest {
@Autowired
private MockMvc mockMvc;
@Test
void testPublicEndpointAccessible() throws Exception {
mockMvc.perform(get("/api/public/test"))
.andExpect(status().isOk());
}
@Test
void testProtectedEndpointRequiresAuthentication() throws Exception {
mockMvc.perform(get("/api/user/profile"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void testUserEndpointWithUserRole() throws Exception {
mockMvc.perform(get("/api/user/profile"))
.andExpected(status().isOk());
}
@Test
@WithMockUser(roles = "ADMIN")
void testAdminEndpointWithAdminRole() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpected(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void testAdminEndpointWithUserRoleForbidden() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpected(status().isForbidden());
}
}
Key Takeaways​
Security Checklist:​
- Always use HTTPS in production
- Encode passwords with BCrypt (strength ≥ 12)
- Implement proper JWT handling with refresh tokens
- Use HttpOnly cookies for sensitive data
- Implement role-based access control appropriately
- Add security headers (CSP, HSTS, etc.)
- Enable audit logging for security events
- Implement rate limiting and account lockout
- Use 2FA for sensitive accounts
- Regular security testing and updates
Performance Considerations:​
- Cache user details to reduce database calls
- Use Redis for session management and rate limiting
- Implement proper connection pooling
- Monitor JWT token size and claims
Monitoring and Alerting:​
- Log all authentication failures
- Monitor for suspicious patterns
- Set up alerts for multiple failed logins
- Track privilege escalation attempts
- Monitor for unusual access patterns
This comprehensive guide covers all major aspects of Spring Security implementation with practical examples and best practices for production applications.