Spring Security Database Authentication: Custom UserDetailsService Guide
Implement database authentication in Spring Security. Learn UserDetailsService, password encoding, account locking, and multi-tenant authentication.
Moshiour Rahman
Advertisement
From In-Memory to Database Authentication
The default in-memory authentication is fine for testing, but production applications need database-backed user management. This guide covers everything from basic setup to advanced multi-tenant scenarios.
Project Setup
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-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Application Configuration
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: postgres
password: secret
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
# Custom security properties
security:
password:
min-length: 8
require-uppercase: true
require-number: true
account:
max-failed-attempts: 5
lock-duration-minutes: 30
Database Schema
Entity Relationship
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │──────<│ UserRole │>──────│ Role │
└─────────────┘ └─────────────┘ └─────────────┘
│
│
┌──────┴──────┐
│ │
┌──────▼─────┐ ┌─────▼──────┐
│RolePermission│ │ Permission │
└─────────────┘ └────────────┘
User Entity
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 100)
private String username;
@Column(unique = true, nullable = false, length = 255)
private String email;
@Column(nullable = false)
private String password;
@Column(name = "first_name", length = 50)
private String firstName;
@Column(name = "last_name", length = 50)
private String lastName;
@Column(nullable = false)
private boolean enabled = true;
@Column(name = "account_non_expired", nullable = false)
private boolean accountNonExpired = true;
@Column(name = "account_non_locked", nullable = false)
private boolean accountNonLocked = true;
@Column(name = "credentials_non_expired", nullable = false)
private boolean credentialsNonExpired = true;
@Column(name = "failed_login_attempts")
private int failedLoginAttempts = 0;
@Column(name = "lock_time")
private LocalDateTime lockTime;
@Column(name = "password_changed_at")
private LocalDateTime passwordChangedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
passwordChangedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> authorities = new HashSet<>();
// Add roles
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
// Add permissions from each role
for (Permission permission : role.getPermissions()) {
authorities.add(new SimpleGrantedAuthority(permission.getName()));
}
}
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
Role Entity
@Entity
@Table(name = "roles")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String name;
@Column(length = 255)
private String description;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
}
Permission Entity
@Entity
@Table(name = "permissions")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 100)
private String name;
@Column(length = 255)
private String description;
}
SQL Migration (Flyway/Liquibase)
-- V1__create_security_tables.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(50),
last_name VARCHAR(50),
enabled BOOLEAN NOT NULL DEFAULT true,
account_non_expired BOOLEAN NOT NULL DEFAULT true,
account_non_locked BOOLEAN NOT NULL DEFAULT true,
credentials_non_expired BOOLEAN NOT NULL DEFAULT true,
failed_login_attempts INTEGER DEFAULT 0,
lock_time TIMESTAMP,
password_changed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description VARCHAR(255)
);
CREATE TABLE permissions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description VARCHAR(255)
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE role_permissions (
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- Indexes
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- Default roles and permissions
INSERT INTO roles (name, description) VALUES
('ADMIN', 'Administrator with full access'),
('USER', 'Standard user'),
('MODERATOR', 'Content moderator');
INSERT INTO permissions (name, description) VALUES
('READ_USERS', 'Can read user data'),
('WRITE_USERS', 'Can create/update users'),
('DELETE_USERS', 'Can delete users'),
('READ_CONTENT', 'Can read content'),
('WRITE_CONTENT', 'Can create/update content'),
('DELETE_CONTENT', 'Can delete content'),
('MANAGE_ROLES', 'Can manage roles and permissions');
-- Assign permissions to roles
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p WHERE r.name = 'ADMIN';
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = 'USER' AND p.name IN ('READ_CONTENT', 'WRITE_CONTENT');
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = 'MODERATOR' AND p.name IN ('READ_CONTENT', 'WRITE_CONTENT', 'DELETE_CONTENT', 'READ_USERS');
Repository Layer
UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
Optional<User> findByUsernameOrEmail(String username, String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
@Modifying
@Query("UPDATE User u SET u.failedLoginAttempts = ?2 WHERE u.username = ?1")
void updateFailedAttempts(String username, int failedAttempts);
@Modifying
@Query("UPDATE User u SET u.accountNonLocked = false, u.lockTime = ?2 WHERE u.username = ?1")
void lockAccount(String username, LocalDateTime lockTime);
@Modifying
@Query("UPDATE User u SET u.accountNonLocked = true, u.lockTime = null, u.failedLoginAttempts = 0 WHERE u.username = ?1")
void unlockAccount(String username);
@Query("SELECT u FROM User u WHERE u.accountNonLocked = false AND u.lockTime < ?1")
List<User> findExpiredLockedAccounts(LocalDateTime expirationTime);
}
RoleRepository
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(String name);
@Query("SELECT r FROM Role r LEFT JOIN FETCH r.permissions WHERE r.name = ?1")
Optional<Role> findByNameWithPermissions(String name);
}
Custom UserDetailsService
Basic Implementation
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
log.debug("Loading user by username: {}", username);
return userRepository.findByUsernameOrEmail(username, username)
.orElseThrow(() -> {
log.warn("User not found: {}", username);
return new UsernameNotFoundException(
"User not found with username or email: " + username
);
});
}
}
Enhanced Implementation with Caching
@Service
@RequiredArgsConstructor
@Slf4j
public class CachingUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final CacheManager cacheManager;
private static final String USER_CACHE = "users";
@Override
@Transactional(readOnly = true)
@Cacheable(value = USER_CACHE, key = "#username")
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
log.debug("Loading user from database: {}", username);
return userRepository.findByUsernameOrEmail(username, username)
.orElseThrow(() ->
new UsernameNotFoundException("User not found: " + username));
}
@CacheEvict(value = USER_CACHE, key = "#username")
public void evictUserCache(String username) {
log.debug("Evicting cache for user: {}", username);
}
@CacheEvict(value = USER_CACHE, allEntries = true)
public void evictAllUsersCache() {
log.debug("Evicting all user cache entries");
}
}
Account Locking & Failed Attempts
Login Attempt Service
@Service
@RequiredArgsConstructor
@Slf4j
public class LoginAttemptService {
private final UserRepository userRepository;
@Value("${security.account.max-failed-attempts:5}")
private int maxFailedAttempts;
@Value("${security.account.lock-duration-minutes:30}")
private int lockDurationMinutes;
@Transactional
public void loginSucceeded(String username) {
userRepository.findByUsername(username).ifPresent(user -> {
if (user.getFailedLoginAttempts() > 0) {
user.setFailedLoginAttempts(0);
userRepository.save(user);
log.info("Reset failed attempts for user: {}", username);
}
});
}
@Transactional
public void loginFailed(String username) {
userRepository.findByUsername(username).ifPresent(user -> {
int attempts = user.getFailedLoginAttempts() + 1;
user.setFailedLoginAttempts(attempts);
if (attempts >= maxFailedAttempts) {
user.setAccountNonLocked(false);
user.setLockTime(LocalDateTime.now());
log.warn("Account locked due to {} failed attempts: {}",
attempts, username);
}
userRepository.save(user);
});
}
@Transactional
public boolean unlockIfExpired(User user) {
if (user.getLockTime() == null) {
return true;
}
LocalDateTime unlockTime = user.getLockTime()
.plusMinutes(lockDurationMinutes);
if (LocalDateTime.now().isAfter(unlockTime)) {
user.setAccountNonLocked(true);
user.setLockTime(null);
user.setFailedLoginAttempts(0);
userRepository.save(user);
log.info("Account automatically unlocked: {}", user.getUsername());
return true;
}
return false;
}
public long getRemainingLockTime(User user) {
if (user.getLockTime() == null) {
return 0;
}
LocalDateTime unlockTime = user.getLockTime()
.plusMinutes(lockDurationMinutes);
return Duration.between(LocalDateTime.now(), unlockTime).toMinutes();
}
}
Authentication Event Listeners
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationEventListener {
private final LoginAttemptService loginAttemptService;
private final CachingUserDetailsService userDetailsService;
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
log.info("Successful login: {}", username);
loginAttemptService.loginSucceeded(username);
}
@EventListener
public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = (String) event.getAuthentication().getPrincipal();
log.warn("Failed login attempt for: {}", username);
loginAttemptService.loginFailed(username);
userDetailsService.evictUserCache(username);
}
}
Custom Authentication Provider with Locking
@Component
@RequiredArgsConstructor
public class LockingAwareAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final LoginAttemptService loginAttemptService;
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails instanceof User user) {
// Check if account is locked
if (!user.isAccountNonLocked()) {
if (!loginAttemptService.unlockIfExpired(user)) {
long remainingMinutes = loginAttemptService.getRemainingLockTime(user);
throw new LockedException(
"Account is locked. Try again in " + remainingMinutes + " minutes."
);
}
}
// Validate password
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("Invalid password");
}
// Check other account states
if (!user.isEnabled()) {
throw new DisabledException("Account is disabled");
}
if (!user.isAccountNonExpired()) {
throw new AccountExpiredException("Account has expired");
}
if (!user.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("Password has expired");
}
}
return new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication);
}
}
Password Policies
Password Validation
@Component
@RequiredArgsConstructor
public class PasswordPolicyValidator {
@Value("${security.password.min-length:8}")
private int minLength;
@Value("${security.password.require-uppercase:true}")
private boolean requireUppercase;
@Value("${security.password.require-lowercase:true}")
private boolean requireLowercase;
@Value("${security.password.require-number:true}")
private boolean requireNumber;
@Value("${security.password.require-special:false}")
private boolean requireSpecial;
public ValidationResult validate(String password) {
List<String> errors = new ArrayList<>();
if (password == null || password.length() < minLength) {
errors.add("Password must be at least " + minLength + " characters");
}
if (requireUppercase && !password.matches(".*[A-Z].*")) {
errors.add("Password must contain at least one uppercase letter");
}
if (requireLowercase && !password.matches(".*[a-z].*")) {
errors.add("Password must contain at least one lowercase letter");
}
if (requireNumber && !password.matches(".*\\d.*")) {
errors.add("Password must contain at least one number");
}
if (requireSpecial && !password.matches(".*[!@#$%^&*(),.?\":{}|<>].*")) {
errors.add("Password must contain at least one special character");
}
return new ValidationResult(errors.isEmpty(), errors);
}
@Data
@AllArgsConstructor
public static class ValidationResult {
private boolean valid;
private List<String> errors;
}
}
Password History (Prevent Reuse)
@Entity
@Table(name = "password_history")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PasswordHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false)
private String password;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
}
@Repository
public interface PasswordHistoryRepository extends JpaRepository<PasswordHistory, Long> {
@Query("SELECT ph FROM PasswordHistory ph WHERE ph.userId = ?1 ORDER BY ph.createdAt DESC")
List<PasswordHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
default List<PasswordHistory> findLastNPasswords(Long userId, int count) {
return findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, count));
}
}
@Service
@RequiredArgsConstructor
public class PasswordHistoryService {
private final PasswordHistoryRepository passwordHistoryRepository;
private final PasswordEncoder passwordEncoder;
@Value("${security.password.history-count:5}")
private int historyCount;
public boolean isPasswordReused(Long userId, String newPassword) {
List<PasswordHistory> history = passwordHistoryRepository
.findLastNPasswords(userId, historyCount);
return history.stream()
.anyMatch(ph -> passwordEncoder.matches(newPassword, ph.getPassword()));
}
@Transactional
public void recordPassword(Long userId, String encodedPassword) {
PasswordHistory history = PasswordHistory.builder()
.userId(userId)
.password(encodedPassword)
.createdAt(LocalDateTime.now())
.build();
passwordHistoryRepository.save(history);
}
}
User Registration Service
@Service
@RequiredArgsConstructor
@Slf4j
public class UserRegistrationService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
private final PasswordPolicyValidator passwordValidator;
private final PasswordHistoryService passwordHistoryService;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public User registerUser(RegistrationRequest request) {
// Validate unique constraints
if (userRepository.existsByUsername(request.getUsername())) {
throw new UsernameAlreadyExistsException(
"Username already exists: " + request.getUsername());
}
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException(
"Email already exists: " + request.getEmail());
}
// Validate password policy
var validation = passwordValidator.validate(request.getPassword());
if (!validation.isValid()) {
throw new InvalidPasswordException(validation.getErrors());
}
// Get default role
Role userRole = roleRepository.findByName("USER")
.orElseThrow(() -> new RuntimeException("Default role not found"));
// Create user
String encodedPassword = passwordEncoder.encode(request.getPassword());
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(encodedPassword)
.firstName(request.getFirstName())
.lastName(request.getLastName())
.enabled(false) // Require email verification
.roles(Set.of(userRole))
.build();
user = userRepository.save(user);
// Record password in history
passwordHistoryService.recordPassword(user.getId(), encodedPassword);
// Publish event for email verification
eventPublisher.publishEvent(new UserRegisteredEvent(user));
log.info("User registered: {}", user.getUsername());
return user;
}
@Transactional
public void changePassword(Long userId, PasswordChangeRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
// Verify current password
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) {
throw new BadCredentialsException("Current password is incorrect");
}
// Validate new password
var validation = passwordValidator.validate(request.getNewPassword());
if (!validation.isValid()) {
throw new InvalidPasswordException(validation.getErrors());
}
// Check password history
if (passwordHistoryService.isPasswordReused(userId, request.getNewPassword())) {
throw new PasswordReusedException(
"Cannot reuse any of your last " + 5 + " passwords");
}
// Update password
String encodedPassword = passwordEncoder.encode(request.getNewPassword());
user.setPassword(encodedPassword);
user.setPasswordChangedAt(LocalDateTime.now());
user.setCredentialsNonExpired(true);
userRepository.save(user);
passwordHistoryService.recordPassword(userId, encodedPassword);
log.info("Password changed for user: {}", user.getUsername());
}
}
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final LockingAwareAuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/moderator/**").hasAnyRole("ADMIN", "MODERATOR")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
Scheduled Account Maintenance
@Component
@RequiredArgsConstructor
@Slf4j
public class AccountMaintenanceScheduler {
private final UserRepository userRepository;
@Value("${security.account.lock-duration-minutes:30}")
private int lockDurationMinutes;
@Value("${security.password.expiry-days:90}")
private int passwordExpiryDays;
@Scheduled(fixedRate = 300000) // Every 5 minutes
@Transactional
public void unlockExpiredAccounts() {
LocalDateTime expirationTime = LocalDateTime.now()
.minusMinutes(lockDurationMinutes);
List<User> expiredLocks = userRepository.findExpiredLockedAccounts(expirationTime);
for (User user : expiredLocks) {
user.setAccountNonLocked(true);
user.setLockTime(null);
user.setFailedLoginAttempts(0);
userRepository.save(user);
log.info("Unlocked expired account: {}", user.getUsername());
}
}
@Scheduled(cron = "0 0 2 * * ?") // Daily at 2 AM
@Transactional
public void expireOldPasswords() {
LocalDateTime expiryDate = LocalDateTime.now().minusDays(passwordExpiryDays);
// This would need a custom query
log.info("Checking for expired passwords older than: {}", expiryDate);
}
}
Summary
| Component | Purpose |
|---|---|
UserDetailsService | Load users from database |
PasswordEncoder | Hash and verify passwords |
LoginAttemptService | Track failed attempts, lock accounts |
PasswordPolicyValidator | Enforce password requirements |
PasswordHistoryService | Prevent password reuse |
| Event Listeners | React to auth success/failure |
Database authentication provides the foundation for enterprise security. In the next article, we’ll add OAuth2 social login for Google and GitHub.
Series Navigation
- Spring Security Architecture
- Database Authentication (This Article)
- OAuth2 & Social Login
- Method-Level Security
- CSRF, CORS & Security Headers
- Production Security Best Practices
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Spring Security Architecture: Complete Guide to How Security Works
Master Spring Security internals. Learn FilterChain, SecurityContext, Authentication flow, and how Spring Security protects your application.
Spring BootSpring Boot JWT Authentication: Complete Security Guide
Implement JWT authentication in Spring Boot 3. Learn Spring Security 6, token generation, validation, refresh tokens, and role-based access control.
Spring BootSpring Security Method-Level Authorization: Complete @PreAuthorize Guide
Master method-level security in Spring Boot. Learn @PreAuthorize, @PostAuthorize, SpEL expressions, custom permissions, and domain object security.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.