Users in the database
Replace the in-memory user with JPA entities, roles, and a database-backed UserDetailsService that accepts username or email.
You will build
users, roles and account-state tables, JPA entities, and a UserDetailsService that logs in by username or email
You will understand
how Spring Security account flags map to columns, why authentication queries fetch roles eagerly, and where the ROLE_ prefix belongs
- six migrations, two entities, one service
OAuth client persistence is only half of the problem. The human user who authenticates at /login also needs durable identity and account state.
In this part we add:
users
roles
user_roles
and implement a database-backed UserDetailsService.
Schema
V4 — users
src/main/resources/db/migration/V4__create_users.sql:
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
V5 — roles
V5__create_roles.sql:
CREATE TABLE roles (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255)
);
V6 — user/role relationship
V6__create_user_roles.sql:
CREATE TABLE user_roles (
user_id UUID NOT NULL,
role_id UUID NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
V7 — Spring Security account state
V7__add_user_account_status.sql:
ALTER TABLE users
ADD COLUMN account_non_expired BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN account_non_locked BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN credentials_non_expired BOOLEAN NOT NULL DEFAULT TRUE;
These booleans map directly to Spring Security’s UserDetails account-state model.
Entity model
erDiagram
USERS ||--o{ USER_ROLES : has
ROLES ||--o{ USER_ROLES : assigned
USERS {
UUID id PK
varchar username UK
varchar email UK
varchar password
boolean enabled
boolean account_non_expired
boolean account_non_locked
boolean credentials_non_expired
}
ROLES {
UUID id PK
varchar name UK
varchar description
}
RoleEntity
Use a UUID primary key, unique role name, and optional description.
UserEntity
Important mapping:
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<RoleEntity> roles = new HashSet<>();
Use @PrePersist / @PreUpdate for application timestamps if you want them managed in Java as well.
Repositories
public interface RoleRepository
extends JpaRepository<RoleEntity, UUID> {
Optional<RoleEntity> findByName(String name);
}
For users:
public interface UserRepository
extends JpaRepository<UserEntity, UUID> {
@EntityGraph(attributePaths = "roles")
Optional<UserEntity> findByUsername(String username);
@EntityGraph(attributePaths = "roles")
Optional<UserEntity> findByUsernameOrEmail(
String username,
String email
);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
Why @EntityGraph? Authentication needs authorities immediately. It is better to explicitly fetch the role relationship than to accidentally depend on an open persistence session.
We already disabled:
spring:
jpa:
open-in-view: false
which makes those boundaries clearer.
Implement DatabaseUserDetailsService
@Service
public class DatabaseUserDetailsService
implements UserDetailsService {
private final UserRepository userRepository;
public DatabaseUserDetailsService(
UserRepository userRepository
) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String login) {
UserEntity user = userRepository
.findByUsernameOrEmail(login, login)
.orElseThrow(() ->
new UsernameNotFoundException(
"Invalid username or email"
)
);
String[] authorities = user.getRoles()
.stream()
.map(role -> "ROLE_" + role.getName())
.toArray(String[]::new);
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.disabled(!user.isEnabled())
.accountExpired(!user.isAccountNonExpired())
.accountLocked(!user.isAccountNonLocked())
.credentialsExpired(!user.isCredentialsNonExpired())
.build();
}
}
The generic error message is intentional:
Invalid username or email
Authentication endpoints should avoid becoming an easy username-enumeration oracle.
Seed the baseline USER role through Flyway
Instead of creating essential role definitions dynamically every boot, seed a deterministic role in
V8__seed_default_user_role.sql:
INSERT INTO roles (id, name, description)
VALUES (
'00000000-0000-0000-0000-000000000001',
'USER',
'Default application user'
)
ON CONFLICT (name) DO NOTHING;
Later add ADMIN the same way, in V9__seed_admin_role.sql:
INSERT INTO roles (id, name, description)
VALUES (
'00000000-0000-0000-0000-000000000002',
'ADMIN',
'Authorization server administrator'
)
ON CONFLICT (name) DO NOTHING;
Password encoding
Never store a raw password.
The application should pass user-provided passwords through the configured Spring PasswordEncoder:
user.setPassword(
passwordEncoder.encode(rawPassword)
);
Authentication then uses PasswordEncoder.matches(...) internally through Spring Security.
Login by username or email
Because findByUsernameOrEmail(login, login) uses the same input for both columns, users can authenticate with either:
user
or:
user@example.com
but the resulting Spring Security principal name remains the canonical username.
That becomes useful later when we store OAuth authorization state under principal_name.
Figure: authentication path
sequenceDiagram
participant B as Browser
participant S as Spring Security
participant UDS as DatabaseUserDetailsService
participant DB as PostgreSQL
B->>S: username/email + password
S->>UDS: loadUserByUsername(login)
UDS->>DB: users + roles
DB-->>UDS: UserEntity
UDS-->>S: UserDetails + ROLE_*
S->>S: PasswordEncoder.matches
S-->>B: authenticated session or failure
Verification
Create a development user (temporarily via SQL or initializer), then start the server and perform the same browser authorization flow.
Test both:
username: user
password: password
and:
username: user@example.com
password: password
Both should authenticate the same account.
Common mistakes
Lazy initialization error for roles
Use an entity graph on authentication queries or another explicit fetch strategy. Do not re-enable Open Session in View simply to hide a repository boundary issue.
Roles missing ROLE_
Spring’s role checks expect authorities such as:
ROLE_ADMIN
Store the role in your domain as:
ADMIN
and add the prefix at the Spring Security boundary.
Hibernate tries to create tables
Confirm:
spring:
jpa:
hibernate:
ddl-auto: validate
Flyway should remain the schema owner.
The code
This part corresponds to these commits in the repository:
aa39e61feat: add user and role persistence schemadb7ba08feat: add database-backed user authentication7326d9cfeat: support account status and email login
Check out the last one to see the project exactly as this part leaves it:
git checkout 7326d9c
References
- Spring Security authentication architecture: https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
- Spring Data JPA reference: https://docs.spring.io/spring-data/jpa/reference/
- Spring Security password storage: https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
Next: public registration, API error handling, and keeping all demo credentials strictly inside the dev profile.