1. The purpose of the UserAccount → UserDetails mapping
After looking up an account in the database, one crucial piece of the entire DB-backed scheme remains — toUserDetails(account). The method is small, but this is exactly where we decide what data DaoAuthenticationProvider will see: login, password hash, account state flags, and authorities. If this step is put together poorly, authentication may still succeed, but permissions and states will start behaving like a lottery.
Honestly, the mapping looks like routine work: take fields from one object and move them into another. But in security, this is not “move them around,” it is “assemble a building pass” that security (Spring Security) will use to let the user in or turn them away. And if the pass is assembled poorly, security is not at fault — it is simply following instructions.
Spring Security specifically does not know anything about your JPA entities, tables, repositories, or “how we modeled everything nicely.” It only knows the contract: it needs a UserDetails object that contains the login, password (more precisely, its hash), a set of permissions (authorities), and account state flags. That is why UserDetailsService is used by DaoAuthenticationProvider to obtain the username, password, and additional user attributes during username/password authentication.
If you try to “trick the system” and return something random from UserDetailsService, such as your JPA entity as a whole, you will get a mix of problems: from extra data in memory to unexpected lazy-loading surprises. So we build a simple, boring, and reliable bridge: UserAccount (domain/database) → UserDetails (security model).
A small diagram to keep the data path in mind:
flowchart TD
DB[(PostgreSQL)]
Repo[UserAccountRepository]
UDS[CustomUserDetailsService]
UD[UserDetails]
DAP[DaoAuthenticationProvider]
DB --> Repo --> UDS --> UD --> DAP
2. UserDetails: the passport, UserAccount: the account
A very common beginner mistake is to treat UserDetails as “just another user model.” Then your hand reaches for everything at once: displayName, bio, avatarPath, the favorite color of the “Save” button, and, as a bonus, the entire publication history. But in terms of meaning, UserDetails is the minimal security snapshot needed by Spring Security to make decisions about sign-in and permissions.
If you look at the UserDetails contract, it quite honestly says what it needs: getUsername(), getPassword(), getAuthorities(), as well as the flags isEnabled(), isAccountNonLocked(), isAccountNonExpired(), isCredentialsNonExpired(). None of this is “for beauty.” This is the data used to decide whether the user can be authenticated and which authorities to grant.
It is useful to keep the comparison in table form. It is not about “the one true way” in the world, but about common sense in our educational project:
| What it is | UserAccount (DB / domain) | UserDetails (security view) | Why |
|---|---|---|---|
| Login | username or |
getUsername() | Identify the user in the auth flow |
| Password | passwordHash | getPassword() | The provider will compare the entered password through PasswordEncoder |
| Permissions | roles (and later, possibly, permissions) | getAuthorities() | Give Spring Security data for / |
| State | enabled, |
isEnabled(), |
Prevent a blocked/disabled account from logging in |
| Profile | displayName, , |
not needed | The profile does not participate in password checks or authority assignment |
The main idea: UserAccount is “how the user is stored,” and UserDetails is “what the security layer needs right now.” When you keep this separation, the code becomes not only safer, but also easier to maintain.
3. Login and password: username and passwordHash
At this stage, you want to say “well, that is obvious,” but bug statistics say otherwise: these two fields are exactly where people manage to make the loudest mistakes. It is important to understand here that UserDetailsService returns data that is already stored in the system, not what the user typed into the login form. What the user typed will go into a separate Authentication object as credentials, and then DaoAuthenticationProvider will compare that to what UserDetailsService returned.
That is, UserDetails#getPassword() is the hash we already stored in the database earlier. The provider will compare the raw password (entered) with the encoded password (from the database) through PasswordEncoder.matches(...). We do not do this by hand, otherwise we lose the point of DaoAuthenticationProvider.
A mini implementation of the “login + password hash” mapping usually looks like this:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
// In the security contract, username is the sign-in identifier (what exactly: login/email — the project decides)
return User.withUsername(account.getUsername())
// IMPORTANT: put the hash from the database here, not the raw password (encoded password)
.password(account.getPasswordHash())
// Authorities are attached to the user when UserDetails is loaded
.authorities(roleNames(account.getRoles()))
.build();
}
Here, the form of the code is not even the main thing; the meaning is. We choose account.getUsername() as what will be the “username” in the security context, and we put passwordHash as the password. Yes, the word password in the builder looks as if we are putting the raw password there, but in reality it means “password used to authenticate” — in our case, the hash.
And one more subtle point: the method name loadUserByUsername tempts you to think that sign-in is always by username. In reality, this is just a historical API name. This slot can also receive email, login, or “whatever we decide to treat as the sign-in identifier.” The key is that inside the project this must be consistent. We are not choosing between username and email right here (that is a separate topic), but the mapping must match whatever you use to query the repository.
4. Account states: enabled and accountNonLocked
When you start working with real users in a database, it quickly turns out that an account can exist and still not have the right to sign in. For example, it was disabled by an admin, or it is locked. This is not “business logic for later” — it is a direct authentication requirement: such users cannot be authenticated, even if the password is correct.
UserDetails directly contains the methods isEnabled() and isAccountNonLocked(), which participate in deciding whether a user can be authenticated. And the User class (the same org.springframework.security.core.userdetails.User) supports these flags in its constructor and model.
And this is where the classic trap begins: in the User builder, you will often see methods in the negative form:
- disabled(true/false) — not enabled(...)
- accountLocked(true/false) — not accountNonLocked(...)
So the code ends up with honest “double negatives” that first look like a bug in the soup and then become a way of life:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
return User.withUsername(account.getUsername())
.password(account.getPasswordHash())
// In the database, enabled=true means "can log in", while in the builder it is disabled=false
.disabled(!account.isEnabled())
// In the database, accountNonLocked=true means "not locked", while in the builder it is accountLocked=false
.accountLocked(!account.isAccountNonLocked())
// Permissions (authorities) must be ready for use in hasRole/hasAuthority
.authorities(roleNames(account.getRoles()))
.build();
}
Here we are doing two things that are very easy to mix up. First, disabled(!enabled): if the account is disabled in the database, then disabled(true). Second, accountLocked(!accountNonLocked): if accountNonLocked=false in the database, then in the builder we set accountLocked(true).
In User.UserBuilder, these methods exist and are named exactly that way (disabled, accountLocked), so this is not “custom magic” — it is the standard Spring Security model.
We are not complicating the other two flags (accountNonExpired, credentialsNonExpired) for now: in the educational project at this stage, they can be left at their default values (that is, “not expired”). The important thing is to pass through enabled and locked correctly, because these are the states actually used in our domain.
5. Roles: authorities and ROLE_
At this point, many people think: “Well, roles are already in the database. So that is it.” But Spring Security makes authorization decisions based on GrantedAuthority. And roles are just one option for authorities (usually with the prefix ROLE_). The documentation explicitly says that hasRole is a shortcut for hasAuthority that adds ROLE_ (or another configured prefix).
This leads to an unpleasant but useful thought: if you forget the prefix, you may have a role ADMIN in the database, but at runtime the user will have authority "ADMIN", while your rules are written as hasRole("ADMIN"), and you will get “why 403, somehow.” And the “somehow” will be exactly four characters: ROLE_.
The simplest and most predictable approach is to store roles in code as an enum, and during mapping produce authority strings with an explicit prefix:
import java.util.Set;
private String[] roleNames(Set<Role> roles) {
return roles.stream()
// By default, Spring Security expects roles as authorities with the ROLE_ prefix
.map(role -> "ROLE_" + role.name())
.toArray(String[]::new);
}
That gives us "ROLE_USER", "ROLE_EDITOR", "ROLE_ADMIN" — exactly what many Spring Security mechanisms expect.
Why is this important specifically at the UserDetailsService level? Because authorities are usually attached to the user precisely when UserDetails is loaded. And then these authorities end up in Authentication and are used by authorization. In Spring Security, authorities are treated as high-level permissions, and roles are one of the standard examples of such permissions.
Another practical tip: do not spread role conversion logic all over the project. As soon as you start doing "ROLE_" + ... in three places, you will get a fourth place where you forget to do it. And you will get “why 403” again, but at a more sophisticated level.
6. Mapping practice
Where to keep the mapping
Mapping code is not the kind of thing you want to read over morning coffee. But this exact part should be as boring and predictable as possible. Good mapping is like a good seat belt: you only remember it at the moment of impact, and then it is already too late to discover that you fastened it to the glove compartment.
In our case, the most practical option is to keep the mapping in a separate private method next to loadUserByUsername(). Then loadUserByUsername() itself stays short: found the account, not found — throw UsernameNotFoundException, found — convert to UserDetails. And the mapping is a separate small function with no side effects.
If you try to put the mapping directly into the repository, your “repository suddenly starts knowing about security.” If you try to put it in configuration, your security configuration will bloat. If you put it in the controller — well... let us not traumatize controllers, they have already been through enough.
And yes, this is that rare case where the method toUserDetails(account) is an excellent name. It does not pretend to be philosophical; it honestly says: “I am converting to a security representation.”
Example: loadUserByUsername and toUserDetails
Now let us assemble the puzzle: the repository returns UserAccount, CustomUserDetailsService turns it into UserDetails, and then Spring Security does its job. The main thing is to keep this code as linear as possible so that you can read it half-asleep and still understand what is going on (this is a real requirement for security code, because problems often surface on Friday evening).
Here is a compact loadUserByUsername() variant that already looks “production-ready” rather than like a stub:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Override
public UserDetails loadUserByUsername(String username) {
return repo.findByUsername(username).map(this::toUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(username));
}
Notice how “non-magical” this is. No manual password checking. No if(hash.equals(raw)). No attempts to solve authorization. We just load data and return it in the expected contract. This is exactly the style that makes UserDetailsService a good partner for DaoAuthenticationProvider.
And here is the very toUserDetails(account) that captures everything important: password hash, account states, and authorities:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
return User.withUsername(account.getUsername())
.password(account.getPasswordHash())
.disabled(!account.isEnabled())
.accountLocked(!account.isAccountNonLocked())
.authorities(roleNames(account.getRoles()))
.build();
}
If this method is implemented correctly, the system behavior will be very clear afterwards. A disabled user will not be able to authenticate, even with the correct password. A locked user will not be able to either. A user without the required role will log in but will be denied access where another role is required. And that is exactly what we are aiming for: predictability.
7. Typical mistakes when mapping UserAccount → UserDetails
At this point, it is worth honestly admitting: mistakes will happen here. Not because you are a bad developer, but because security loves “small strings that decide everything.” The good news is that these mistakes are almost always repeated, and you can learn to recognize them by smell.
Mistake #1: put the raw password into UserDetails instead of passwordHash.
This is especially sneaky if you test with a single user and “it seems to work.” In reality, you are breaking the whole model: DaoAuthenticationProvider expects an encoded password from the user store and compares it through PasswordEncoder. A raw password in the database or in UserDetails is no longer a shortcut, but a real vulnerability and an architectural mistake.
Mistake #2: confuse “locked” and “non locked.”
When you see accountNonLocked in the database and accountLocked in the builder, the brain tries to make it “somehow.” As a result, you either accidentally lock all users or, on the contrary, allow locked users to sign in. The correct rule is simple: if accountNonLocked=false in the database, then the builder must set accountLocked(true).
Mistake #3: forget that hasRole expects the ROLE_ prefix.
You store roles in the database as ADMIN, return authority "ADMIN", and then use hasRole("ADMIN") in the rules. And then the favorite beginner game begins: “why 403, I am an admin.” The answer: you are an admin in the database, but not ROLE_ADMIN in Spring Security.
Mistake #4: return an empty set of authorities and consider that normal without an explicit decision.
Technically, a user can exist with no roles, but then they will almost always behave strangely: they can log in, but “nothing is allowed.” Sometimes this is an acceptable model (for example, the admin assigns a role later), but then it must be a conscious decision and a well-diagnosed state. Otherwise, you will spend time looking for “where authorization broke,” even though it broke at the data level.
Mistake #5: drag profile fields into UserDetails.
displayName and avatarPath seem harmless, but you gradually turn the security layer into a storage bin for everything. This bloats the context, complicates serialization, may drag in unexpected dependencies, and generally breaks the separation between UserAccount / UserProfile. UserDetails should remain compact.
GO TO FULL VERSION