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 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 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 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 Security OAuth2: Complete Social Login Guide (Google, GitHub)
Implement OAuth2 social login in Spring Boot. Learn Google, GitHub authentication, custom OAuth2 providers, and combining with JWT.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.