🔐 Spring Security, Part III: Custom UserDetailsService with Database Authentication
🔑 Build real-world authentication with Spring Security, Kotlin, and PostgreSQL — no more in-memory users, it's time to go database-backed.
Welcome back to the Spring Security Series!
If you haven’t read the previous tutorial, I highly recommend starting there — it sets the stage for what we’re about to implement.
In this post, we’ll take a big step closer to real-world authentication by integrating Spring Security with a PostgreSQL database using a custom UserDetailsService.
🧭 What You’ll Learn
Dive into Spring Security’s authentication flow
Understand filter-based architecture
Set up database tables for users and authorities
Define
UserEntityandAuthorityEntityImplement a custom
UserDetailsServiceSecure endpoints using database-backed credentials
🧠 Prerequisites
PostgreSQL basics
Optional: Familiarity with Spring Data JPA
🧪 Theory First: Spring Security Architecture
Spring Security uses a filter chain to process incoming HTTP requests. Here’s a simplified architecture of how that works:
🧱 Filter-Based Flow
The Servlet Container receives the request
The request passes through a series of Filters (authentication, logging, etc.)
An AuthenticationManager delegates the authentication task to one or more AuthenticationProviders
The selected provider (e.g.
DaoAuthenticationProvider) uses:
A
UserDetailsServiceto fetch user detailsA
PasswordEncoderto verify credentials
Here are the key interfaces:
@FunctionalInterface
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}In our case, DaoAuthenticationProvider will authenticate users from the database using these two beans.
🏗 Project Setup
Here’s your build.gradle.kts:
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.5.4"
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "1.9.25"
}
group = "com.atomic.coding"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
tasks.withType<Test> {
useJUnitPlatform()
}📦 Simple Controller for Testing
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderService: OrderService
) {
@GetMapping
fun orders(): List<Order> {
return orderService.orders()
}
}🔐 Default Spring Security Behaviour
By default, Spring Security protects all routes with HTTP Basic authentication. If you run the app and request /api/orders, you’ll receive:
But check your logs — Spring generates a default password:
Using generated security password: 8cbecab7-4d14-4c38-b9ca-2d072b8ba8f6Use this to authenticate, and you’ll get:
Nice! But now let’s replace that with database-backed authentication.
🧾 Database Schema
Here’s our migration script:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE authorities (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE users_authorities (
user_id SERIAL NOT NULL,
authority_id SERIAL NOT NULL,
PRIMARY KEY (user_id, authority_id)
);
-- Pre-seed user 'alice' with hashed password 'qwerty'
INSERT INTO users(username, password)
VALUES ('alice', '$2a$10$hc0ust6BkOkGSCNemqWHC.zKgpCuEIN.Qx/2XTSYB11Qxot8bfDIS');
INSERT INTO authorities(name)
VALUES ('READ'), ('WRITE'), ('UPDATE');
INSERT INTO users_authorities(user_id, authority_id)
VALUES (1, 1), (1, 2), (1, 3);🧬 JPA Entity Mapping
UserEntity & AuthorityEntity
@Entity
@Table(name = "users")
data class UserEntity(
@Id @Column(name = "id")
val id: Long, @Column(name = "username")
val username: String,
@Column(name = "password")
val password: String,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "users_authorities",
joinColumns = [JoinColumn(name = "user_id")],
inverseJoinColumns = [JoinColumn(name = "authority_id")]
)
val authorities: List<AuthorityEntity>,
)
@Entity
@Table(name = "authorities")
data class AuthorityEntity(
@Id val id: Long,
@Column
val name: String,
@ManyToMany(mappedBy = "authorities")
val users: List<UserEntity>
)🔐 Core Spring Security Interfaces
UserDetailsService
Next, let’s dive deeper into the UserDetailsService—a core interface used by Spring Security for user authentication.
This interface defines the contract between your application and Spring Security’s authentication framework. Whenever a user attempts to log in, Spring Security calls loadUserByUsername() to fetch the corresponding user details.
The source of the data doesn’t matter — it could come from an in-memory store, a database, or even an external API. What matters is that this method returns a valid object implementing the UserDetails interface.
interface UserDetailsService {
fun loadUserByUsername(username: String): UserDetails
}UserDetails
The UserDetails interface represents the authenticated user. It defines core user properties required for authentication and authorization:
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
default boolean isAccountNonExpired() { return true; }
default boolean isAccountNonLocked() { return true; }
default boolean isCredentialsNonExpired() { return true; }
default boolean isEnabled() { return true; }
}Most commonly, you’ll work with these three methods:
getUsername()– returns the login identifier.getPassword()– returns the encoded password.getAuthorities()– returns a list of roles or permissions (GrantedAuthority).
🛡️ GrantedAuthority and SimpleGrantedAuthority
GrantedAuthority represents a permission or role assigned to the user. For example, READ, WRITE, or ROLE_ADMIN.
public interface GrantedAuthority extends Serializable {
String getAuthority();
}Spring provides SimpleGrantedAuthority, a built-in implementation used to wrap permission strings. We'll use this to expose our users’ authorities to the framework.
🧩 Our Task
To integrate with Spring Security, we need to:
Define a repository to fetch user data from the database.
Map
UserEntityto a Spring-compatibleUserDetailsimplementation.Implement a custom
UserDetailsServiceto tie it all together.
UserRepository
We define a simple Spring Data JPA repository for accessing user records
interface UserRepository : JpaRepository<UserEntity, Long> {
fun findByUsername(username: String): UserEntity?
}SecurityUser
SecurityUser wraps UserEntity and exposes its data in a format Spring Security understands:
class SecurityUser(
private val userEntity: UserEntity
) : UserDetails {
override fun getAuthorities(): List<GrantedAuthority> =
userEntity.authorities.map { SimpleGrantedAuthority(it.name) }
override fun getPassword(): String = userEntity.password
override fun getUsername(): String = userEntity.username
}UserService(Implementing UserDetailsService)
This service retrieves the user from the database and adapts it for Spring Security:
@Service
class UserService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepository.findByUsername(username)
?: throw UsernameNotFoundException("User not found with username: [$username]")
return SecurityUser(user)
}
} 🔧 Security Configuration
Finally, configure Spring Security with a password encoder and a basic HTTP security policy:
@Configuration
class SecurityConfig {
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
.httpBasic { }
.authorizeHttpRequests { httpRequest -> httpRequest.anyRequest().authenticated() }
.build()
}Key points:
BCryptPasswordEncoderensures passwords are securely hashed.All routes require authentication by default.
HTTP Basic Auth is enabled for simplicity (suitable for development or API testing)
🧪 Testing the Setup
Try sending requests using Postman, curl, or HTTPie:
✅ Valid credentials (alice:qwerty) → 200 OK
❌ Invalid credentials → 401 Unauthorized
📋 Bonus: Log Authenticated User Info
SecurityContextHolder is a core class in Spring Security that holds the security context of the current execution thread. When a request is authenticated, Spring security populates SecurityContextHolder with an Authentication object, that represents a user’s information
✅ More specifically:
It provides access to the
SecurityContext, which holds the currentAuthenticationobject.The
Authenticationobject contains:principal– usually the user details (like username or a UserDetails object)credentials– password (usually hidden or empty after login)authorities– user roles or permissions
In the code snippet below, we log the authenticated user and their authorities.
@GetMapping
fun orders(): List<Order> {
val authentication = SecurityContextHolder.getContext().authentication
logger.info("AuthenticatedUser: ${authentication.name}")
authentication.authorities.forEach {
logger.info("GrantedAuthority: $it")
}
return orderService.orders()
}2025-08-02T10:39:01.608+03:00 INFO 39587 --- [managing-users] [nio-8080-exec-2] c.a.coding.controler.OrderController : AuthenticatedUser: alice
2025-08-02T10:39:01.608+03:00 INFO 39587 --- [managing-users] [nio-8080-exec-2] c.a.coding.controler.OrderController : GrantedAuthority: READ
2025-08-02T10:39:01.608+03:00 INFO 39587 --- [managing-users] [nio-8080-exec-2] c.a.coding.controler.OrderController : GrantedAuthority: WRITE
2025-08-02T10:39:01.608+03:00 INFO 39587 --- [managing-users] [nio-8080-exec-2] c.a.coding.controler.OrderController : GrantedAuthority: UPDATE✅ Recap — What We Covered
In this tutorial, we took a major step toward real-world authentication using Spring Security and PostgreSQL. Here’s a quick summary of what you learned:
✅ Spring Security’s default behaviour and auto-generated credentials
✅ How the filter-based authentication flow works under the hood
✅ Creating and seeding the PostgreSQL schema for users and authorities
✅ Mapping database tables using JPA entities (
UserEntity,AuthorityEntity)✅ Building a custom
UserDetailsServicefor loading users from the database✅ Configuring Spring Security with a secure
PasswordEncoderand filter chain✅ Logging the authenticated user and their permissions for debugging and audit
With these pieces in place, your app now authenticates users based on real data, not just a hardcoded or in-memory setup. 🎉
🚀 What’s Next?
In the next part of this series, we’ll level up our security setup by covering:
🔧 Implementing Custom Authentication using a custom
Filter🔐 Supporting Multiple Authentication Providers (e.g., database + external service)
🛡 Building a more secure, extensible, and production-ready authentication flow
We’re moving beyond the basics into enterprise-grade security architecture — stay tuned!
If you found this helpful, don’t forget to like, share, or comment.
Thanks for reading — see you in the next part! 👋





