CodeGym /Courses /Spring Security /Loading authorities from the DB

Loading authorities from the DB

Spring Security
Level 15, Lesson 3
Available

1. Authorities: a separate class of bugs

When we first move users into the DB, the brain habitually looks for the problem where “the scariest things live”: in the password and PasswordEncoder. And that is logical: a password is a sensitive thing, and encoders look like magic. But in real projects, very often the password has nothing to do with it at all: authentication succeeds, authorization does not, and the user starts getting access denials to those endpoints that were supposedly “available by role”.

Let us state an important idea in simple words. Authentication answers the question “who are you?”, and authorization answers “are you allowed?”. In Spring Security, this separation is visible technically: DaoAuthenticationProvider can successfully return Authentication (that is, “the user is confirmed”), but inside that Authentication there may be an empty or incorrect set of GrantedAuthority. And then later, when the request reaches a rule like hasRole("EDITOR") or hasAuthority("user:manage"), the answer will be negative.

This is especially tricky in a project like Secure Content Platform API, where the zones are very different: /api/public/** is almost always open, /api/me/** requires only authentication, and /api/editor/** and /api/admin/** depend on roles. If roles were loaded incorrectly, you get a strange UX: “I logged in, so I exist,” but “I am an editor, but I cannot enter the editor area.” And you, as the developer, start doubting reality rather than the "ROLE_" strings.

2. The path of authorities from the DB to the SecurityContext

To fix permission problems, it helps to keep a short “authority path map” in mind — from the table in the DB to the SecurityContext. In a DB-backed scheme, the source of permissions is your data in UserAccount (for example, roles), and the consumer is Spring Security, which expects a set of GrantedAuthority inside UserDetails, and then moves them into the final Authentication.

In simplified form, it can be represented like this:

flowchart TD
    DB[(DB: user_account + roles)] --> Repo[UserAccountRepository]
    Repo --> UDS[CustomUserDetailsService]
    UDS --> UD[UserDetails: username + passwordHash + authorities]
    UD --> Provider[DaoAuthenticationProvider]
    Provider --> Auth["Authentication(authenticated=true)"]
    Auth --> SC[SecurityContext]

Pay attention to the place where permissions most often “leak”: this is the mapping UserAccount → UserDetails. The repository usually gives you UserAccount, its roles may be empty, not loaded, or stored in an unexpected format. And UserDetails is already the “final” object for the security layer: if you put the wrong authorities into it, Spring Security will honestly use what you gave it.

In code it looks roughly like this: loadUserByUsername(...) loaded the account, then toUserDetails(account) built the UserDetails, and somewhere inside it called your roleNames(...) method or something similar. If roleNames(...) returned an empty array or strings without the required prefix, you already lost. Not because Spring Security is bad, but because it is literally executing your instruction.

The most unpleasant part is that such errors often do not show up at login time. Login may succeed “successfully” because the password matched and the account is not blocked. But the access denial appears later, when you go into a protected zone. That is why this class of bugs has a typical symptom: “login works, but the role does not.”

3. Empty roles: login exists, access does not

On paper, it seems that a user “without roles” is just a “very simple user.” But in security, there are almost no neutral states. Empty roles in the DB are either a data bug (the user was created incorrectly), or a migration bug, or a mapping bug (the roles were there, but you did not see them). And it may also be an intentional model, but then it must be fixed as a project rule and reflected in access control.

In our course and project, the roles USER / EDITOR / ADMIN are the backbone of the model. If the user has none of them, you get a strange state. They can authenticate (the password is correct), but then:

  • they can enter a zone where it is enough to “just be authenticated”;
  • they cannot enter zones tied to roles (/api/editor/**, /api/admin/**);
  • and you start debugging SecurityFilterChain, even though the problem lives in the data.

The most dangerous thing here is not the denial, but unpredictability. Today you have an endpoint available to any authenticated user (for example, /api/me), and the user “without roles” can reach it. Tomorrow you add a “ROLE_USER only” rule, and that same user suddenly breaks. If such accounts exist, the system will constantly wobble between scenarios. This is awful for support and very bad for security, because exceptions and unexpected branches usually lead to chaotic “temporary fixes.”

Therefore, at the fundamentals level, it is reasonable to establish a simple rule: an empty set of roles is an invalid state, and it is better to catch it early and loudly. For example, directly in the role mapping:

import java.util.Set;

public final class RoleMapping {

    public static void requireNonEmpty(Set
   roles, String username) {
        // In security it is better to fail early and loudly: empty roles almost always mean a data/mapping bug.
        if (roles == null || roles.isEmpty()) {
            throw new IllegalStateException("User has no roles: " + username);
        }
    }
}

Yes, that looks “strict.” But in a teaching project, strictness is not evil; it is a way to spot the problem quickly and stop guessing. If a user without roles accidentally appeared in your DB, it is better to see that as an explicit error than to get a “silent” access denial somewhere three steps later.

If you deliberately want to support a “guest with login but without a role” scenario (sometimes this happens, for example when the email is not fully confirmed), then this is already a design decision for the account model. But notice: today we are still not building a rich lifecycle — today it matters that DB-backed auth works predictably, not like a lottery.

4. ROLE_ prefix and hasRole(...)

Now for the most popular reason for “I am an editor, but it will not let me in”: the ROLE_ prefix. I often joke that ROLE_ is like a space in a Wi-Fi password: it seems tiny, but it breaks everything. And here the joke is, unfortunately, far too real.

The essence of the problem

In Spring Security, a role is not a separate entity. It is an authority with a naming convention. In other words, the “ADMIN role” in GrantedAuthority terms is usually represented as the string "ROLE_ADMIN".

This leads to a very specific consequence: when you write hasRole("ADMIN") in the configuration, Spring Security under the hood checks for the authority "ROLE_ADMIN". If you loaded UserDetails with the authority "ADMIN" (without the prefix), then hasRole("ADMIN") will not work. And vice versa, if you use hasAuthority("ROLE_ADMIN") but loaded "ADMIN", that will not work either.

To avoid confusion, it is convenient to remember a small table (and yes, this is one of those cases where a table saves nerves):

What is written in the access rules What must actually be in GrantedAuthority
hasRole("ADMIN") ROLE_ADMIN
hasRole("EDITOR") ROLE_EDITOR
hasAuthority("ROLE_ADMIN") ROLE_ADMIN
hasAuthority("ADMIN") ADMIN

And now let us add one more “minefield” situation: you decided to store roles in the DB already with the ROLE_ prefix (for example, as the string "ROLE_ADMIN"), and in the mapping you add the prefix again. Then you get "ROLE_ROLE_ADMIN". And it looks funny right up until the moment you try to understand why the admin is “not admin”.

Typical bug: lost prefix

private String[] roleNames(UserAccount account) {
    return account.getRoles().stream()
            // "ADMIN"/"EDITOR"/"USER" are returned here without the prefix, and hasRole(...) will not work later.
            .map(role -> role.name())
            .toArray(String[]::new);
}

With this code, the user will have authorities "ADMIN", "EDITOR", "USER". If you use SecurityFilterChain with hasRole("ADMIN"), access will not work.

The correct version for roles:

private String[] roleNames(UserAccount account) {
    RoleMapping.requireNonEmpty(account.getRoles(), account.getUsername());

    return account.getRoles().stream()
            // hasRole("ADMIN") checks "ROLE_ADMIN", so we add the prefix explicitly and in one place.
            .map(role -> "ROLE_" + role.name())
            .toArray(String[]::new);
}

A single way to work with the prefix

The most common cause of such bugs is “smeared mapping.” One developer added "ROLE_" in one place, another in another place, and a third one decided “let us store it in the DB as is.” At the fundamentals level, the best rule is: one place, one way. One roleNames(...) method, one prefix, one agreement.

If you want a bit more discipline, you can move authority construction into a small utility class (without overdoing it):

public final class Authorities {

    private Authorities() {
    }

    public static String role(String roleName) {
        // Project-wide convention: all roles in Spring Security come as authorities with the ROLE_ prefix.
        return "ROLE_" + roleName;
    }
}

And then the mapping becomes almost self-documenting:

private String[] roleNames(UserAccount account) {
    return account.getRoles().stream()
            // Do not smear string magic across the project: all prefix logic lives in Authorities.role(...).
            .map(role -> Authorities.role(role.name()))
            .toArray(String[]::new);
}

Yes, it is just a string. But it is the string that determines whether the user will enter /api/admin/**. In security, “just a string” is usually the main problem.

5. Lazy loading roles during authentication

The topic of “lazy loading” sounds as if it were some philosophy: “we will not load it right away, maybe we will not need it.” And in general, the idea is fine. But in authentication, roles will always be needed right now, because within the next milliseconds after login the system will already make “allowed/not allowed” decisions. That is why lazy loading of roles is a common cause of unexpected failures and empty authorities.

What is lazy loading in very simple words: the UserAccount entity was loaded from the DB, but the related roles data was not loaded immediately. Instead, the ORM leaves a “stub”: when you first access account.getRoles(), the ORM will try to go to the DB and load the roles. That “will try” is the key phrase. For it to work, one condition must be met: the ORM must have a live “session” (in JPA, usually this is an active persistence context inside a transaction).

And this is where the magic begins, in the bad sense. loadUserByUsername(...) is called by Spring Security during authentication. And if at that moment you:

- loaded UserAccount without roles, - stepped outside the transaction or persistence context, - and then tried to build authorities from account.getRoles(),

then the ORM no longer has a way to load the roles, and you will get the classic LazyInitializationException. Beginners usually see this as “something broke in Spring Security.” In reality, Spring Security has nothing to do with it — it simply asked for user details, and you gave it an object that cannot assemble itself correctly.

To understand this, imagine a situation with an office badge. You were given an employee card (UserAccount), but the list of access rights (roles) was not attached. And they say: “do not worry, the access rights will be pulled later, when you go through the turnstile.” But the turnstile is in another building, there is no connection, the guard is angry — and “later” turns into “never.”

6. How to guarantee role loading

What we need here is not a new mandatory project snapshot, but one guarantee: by the time toUserDetails(...) runs, the roles are already available. This can be achieved with different techniques. Below are two clear options that are useful both as working solutions and as ways to quickly diagnose why the authorities ended up empty.

Option A: a separate repository method for “user + roles”

This is my favorite option for a teaching project because it is the most readable. We state it explicitly: “for authentication I need the user together with roles.” And we create a separate repository method.

This is the most direct path: the auth request immediately brings user + roles, with no hope placed on lazy loading.

For example, via fetch join:

import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserAccountRepository {

    @Query("""
           select a from UserAccount a
           left join fetch a.roles
           where a.username = :username
           """)
    // Explicitly load roles in the same query so we do not depend on lazy-loading at authentication time.
    Optional<UserAccount> findByUsernameWithRoles(@Param("username") String username);
}

(If you have ManyToMany, sometimes people add select distinct a ..., to avoid duplicates, but for the first understanding this is enough.)

And then CustomUserDetailsService uses exactly this method:

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@Override
public UserDetails loadUserByUsername(String username) {
    // We take exactly "user + roles", otherwise later in toUserDetails(...) we may get empty authorities.
    UserAccount account = repository.findByUsernameWithRoles(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

    return toUserDetails(account);
}

The advantage of this approach is that you do not hope for “later.” The roles arrive immediately — which means toUserDetails(...) can safely build authorities.

The idea matters more than the exact syntax. The same guarantee can be expressed through fetch join, @EntityGraph, or another readable repository mechanism. The main thing is that the auth path must actually bring user + roles, not promise to do it “sometime later.”

Option B: a transaction around loadUserByUsername(...)

The second path is to give the ORM a chance to load roles lazily, but guarantee that at the moment of calling getRoles() the persistence context is still alive. For this, loadUserByUsername(...) is often marked as transactional:

import org.springframework.transaction.annotation.Transactional;

@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) {
    // The transaction keeps the persistence context alive so that lazy relations (roles) can be loaded when accessed.
    UserAccount account = repository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

    return toUserDetails(account);
}

The point here is not that “Spring Security requires a transaction.” The point is that you create a safe environment where lazy relations can be loaded. This helps, but there is a subtlety: such code can hide problems in the request structure. In other words, everything may seem to “work,” but under the hood multiple queries may happen instead of one.

This is a working technique, but it is useful to see it specifically as a way to keep lazy loading under control, not as the second mandatory project baseline. If the auth repository already knows how to bring roles explicitly, the behavior is usually more transparent.

The most important thing here is not the syntax but the guarantee. It is most reliable when the auth request explicitly brings the user together with roles. A transaction can also keep everything working, but then part of the behavior remains tied to the persistence context.

Whichever option you choose, the meaning is the same: toUserDetails(...) should not hope that roles will “somehow be pulled later.”

7. Diagnosing authorities in the SecurityContext

When you suspect that roles are being loaded differently from what you think, the fastest way is to stop thinking and start looking. But you must look carefully: in security logs, do not accidentally leak anything extra (for example, a password, a token, or the whole account object). We only care about the username and the list of authorities.

One simple teaching trick is to temporarily log what you actually return to UserDetails. For example, directly in toUserDetails(...):

import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

public class CustomUserDetailsService {
    private static final Logger log = LoggerFactory.getLogger(CustomUserDetailsService.class);

    private UserDetails toUserDetails(UserAccount account) {
        String[] authorities = roleNames(account);

        // Log only username and authorities: no need to drag passwords/hashes/tokens here.
        // This kind of log is better kept at DEBUG and used temporarily for diagnosis.
        log.debug("Loaded user={} authorities={}", account.getUsername(), Arrays.toString(authorities));

        return User.withUsername(account.getUsername())
                .password(account.getPasswordHash())
                .authorities(authorities)
                .build();
    }
}

Notice that we do not log passwordHash and certainly not the raw password (it is not even here). We log only what helps us understand the problem: which authorities Spring Security receives as input.

If the log shows authorities=[ADMIN] while you expect ROLE_ADMIN, you already know where to dig. If you see authorities=[] or do not see the log at all (meaning toUserDetails(...) was not called the way you think), that is also a useful signal.

In a project, it is also convenient to test access with a control endpoint. For example, if you already have /api/admin/users, it should be accessible only to the admin. If the admin does not get through, in 90% of cases the problem is either in how you named the authority, or in the fact that the roles were not loaded.

8. Typical mistakes when loading authorities from the DB

Mistake #1: “The user logged in, so the roles are definitely there.”
This is the most deceptive logic. The password may be verified, enabled=true, the account is not blocked — and authentication succeeds. But if the authorities are empty or incorrect, authorization will break later, and you will spend time in the wrong place. The habit of checking authorities as carefully as the password saves hours.

Mistake #2: losing or duplicating the ROLE_ prefix.
When in one place you use hasRole("ADMIN"), and in another you load "ADMIN" without a prefix, the system looks “broken” even though, formally, everything works. Prefix duplication (ROLE_ROLE_ADMIN) is even funnier: it looks almost true, so the eye easily misses it. Only one thing helps: a single mapping place and a single agreement on strings.

Mistake #3: “The roles will be pulled later” with lazy loading.
Lazy loading is a convenient mechanism, but in authentication it often turns into a mine. If the roles are needed right now, it is better to load them explicitly “right now”: through a separate repository method or via @EntityGraph/fetch join. Hope for “later” usually ends in an exception or an empty set of authorities.

Mistake #4: treating an empty role list as a normal state.
Sometimes a developer sees roles=[] and thinks, “well, that happens.” In a security model, that almost always means data or mapping inconsistency. If you have not defined a separate role/state for a “user without rights,” it is better to consider empty roles an error and catch it immediately, before the bug becomes a “behavioral feature.”

Mistake #5: pulling extra domain data into the auth request.
When an authentication request suddenly starts loading the profile, avatar, drafts, and half the content, you increase the likelihood of lazy-loading problems and make debugging harder. UserDetailsService should be boring and predictable: account, password hash, states, roles/authorities — and that is it. Everything else only gets in the way at login time.

1
Task
Spring Security, level 15, lesson 3
Locked
Loading roles for the auth path via EntityGraph
Loading roles for the auth path via EntityGraph
1
Task
Spring Security, level 15, lesson 3
Locked
Empty roles as an explicit error
Empty roles as an explicit error
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION