1. In-memory: stage and limitations
Before we ceremoniously declare in-memory users “bad” and send them into retirement, it is important to acknowledge their merits. In-memory users are like training wheels on a bicycle: they do not make you a champion, but they give you a chance to get to the store for bread without kissing every curb. This is a methodically correct stage, and we needed it.
In the early lectures, we needed to quickly obtain a working UserDetailsService so we could see how DaoAuthenticationProvider checks the password, how the SecurityContext is populated, and how SecurityFilterChain makes the “allow/deny” decision. InMemoryUserDetailsManager is literally an implementation of UserDetailsService that stores users in memory, in a regular Map-based structure, and is intended specifically for such a scenario.
On top of that, the in-memory model is great for demonstrations and local checks: you can clearly see users, roles, and passwords (more precisely, their hashes), and you can manually verify in Postman or in a browser that the editor zone really is the editor zone, and the admin zone is truly closed to a regular user. For educational purposes, it is almost the ideal tool.
But just like training wheels, in-memory has a moment when they start to get in the way. And that moment arrives exactly when you stop building a demo and start building a product.
Accounts in code: when this gets in the way
When users are defined directly in Java configuration, it looks “convenient” right up until the first request: “make it like a real service.” As long as you have one admin user and one user, you feel like a god. But as soon as you need to manage accounts, roles start to smell not like freedom, but like deployment. And deployment, as we know, is not the most convenient way to “ask the user to change the password.”
Let us look at a classic educational example:
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Bean
UserDetailsService users() {
// For educational purposes, the user is created directly in the configuration.
// This is convenient for a demo, but scales poorly in a product (see the text below).
var user = User.withDefaultPasswordEncoder()
// Login (username) — the value Spring Security will use to find the account.
.username("user")
// The password is set here only for getting started.
// Important: withDefaultPasswordEncoder() is a “getting started” shortcut, not for production.
.password("password")
// Roles are the future “access matrix” (USER/EDITOR/ADMIN, etc.).
.roles("USER")
.build();
// InMemoryUserDetailsManager stores accounts in the process memory.
return new InMemoryUserDetailsManager(user);
}
It illustrates the idea perfectly, but two problems are visible right away. First, the account is part of the code. Second, withDefaultPasswordEncoder() is a convenient “getting started” shortcut that Spring Security itself considers unsafe for production and recommends using only for demos/startup.
And even if you have already “fixed” things and are using a proper PasswordEncoder, a fundamental issue remains: the account lives in the code repository. That means any change to the user list, their roles, or their state turns into a chain of “edit code → commit → CI → deploy.” For product logic, that sounds roughly like: “to block a user, you have to ship a release.” Funny, but it makes you want to cry.
There is also a more down-to-earth problem: as soon as accounts end up in code, you inevitably start thinking, “well, this is just a learning project, we can do it this way.” That is a dangerous habit. Security is the area where a bad habit sticks quickly, and unlearning it hurts.
Process memory != storage: amnesia
The word “in-memory” sounds almost like “in-money” (which, by the way, is a pretty good metaphor: money also likes to disappear). In essence, it means one simple thing: data lives in the process’s RAM. A process has one extremely unpleasant bug for data storage: it sometimes dies. And sometimes even on schedule — for example, during deployment.
In the in-memory model, you can perform nice actions that look like “user management,” but in reality it is a temporary illusion. Suppose you “updated” the password or “changed” the roles ( InMemoryUserDetailsManager has management methods because it implements UserDetailsManager). You were happy, you checked — everything works. And then the application restarted, and you were back in the Stone Age: everything returned to what was in the code.
In real-world conditions, restarts are normal. They happen because of releases, because of crashes, because of configuration changes, sometimes simply because that is how life works. And if the user base is “reset” on restart, then it is not a user base, but a decoration.
There is also a second layer: as soon as the application starts living in more than one instance (it does not even have to be “microservices,” just two instances behind a load balancer), in-memory users become “local” to each instance. One instance “knows” about the user; the other does not. And that is no longer a learning curiosity, but a real class of problems that hits you suddenly, at the most inconvenient moment.
So the main conclusion of this section is very simple and very engineering-oriented: RAM is a place for computations and caches, but not for domain entities that need to live a long time. Accounts are exactly such entities.
2. Product requirements at this stage
At the early stage of the course, our project only needed three “characters”: USER, EDITOR, and ADMIN. And that is normal — we were learning how to build access rules and verify that they actually work. But the domain of Secure Content Platform API is structured so that users are not decoration there, but a key part of the system: there are authors, editors, administrators, and each has their own area of responsibility.
When you start to perceive the project as a product (even if it is a learning product), the requirements change instantly. You need accounts that survive a restart. You need to be able to change roles not through the IDE, but as an “administration operation.” You need the ability to disable an account, block it, or temporarily restrict access. You need the user to have a profile that they can change, and for those changes not to turn into a risk of accidentally “touching” the security core.
To make this visible not just at the slogan level, it is useful to look at it as a table of requirements and their consequences:
| Product requirement | What it means for backend | Why in-memory breaks |
|---|---|---|
| Accounts survive restarts | The user is data, not code | After a restart, everything returns to a hardcoded state |
| Role management | A role changes as an operation, not as a commit | You need a deploy to “grant admin rights” |
| Blocking/disabling | An account has a “can/cannot sign in” state | In code, it is either missing or becomes chaos |
| Transparent seed for learning | There must be starter users for checking scenarios | It exists in code, but it is still “life inside the application” |
| User profile and content | An account is not just a login, but a link to data | In-memory store does not become the “truth” for the domain |
This does not mean that in-memory can “never” be used. It remains an excellent tool for tests and local demos. But as soon as you want to make the user part of the domain model and rely on it in business scenarios, in-memory stops being a foundation.
From here comes the practical boundary: we do not yet need to rebuild the entire login flow on top of the database. First, it is important to fix the account model, roles, and storage point so that DaoAuthenticationProvider can actually get a normal data source. Once this part becomes stable, it can be connected to standard username/password authentication without reinventing the entire security model.
3. Moving to the DB: what stays the same
Unchanged part: access matrix and auth flow
A very common beginner mistake is to perceive the transition to a database as “we are building a new security model now.” And here I want to say gently but firmly: no, you are not reinventing security. You are changing the data source for the user. The mechanics of authentication and authorization remain the same as the ones we already assembled and verified.
If we speak the language of Spring Security, then DaoAuthenticationProvider (which in our mental model is responsible for username/password) will continue to use UserDetailsService + PasswordEncoder as it did before. The only difference is that instead of InMemoryUserDetailsManager, we give it a UserDetailsService that reads the user from the persistence layer.
That is why it is appropriate to draw such a diagram at the architecture level, without storage details:
flowchart TD
R[HTTP request] --> FC[SecurityFilterChain]
FC --> AP[DaoAuthenticationProvider]
AP --> UDS[UserDetailsService]
UDS --> STORE[(User store)]
STORE --> UDS
AP --> SC[SecurityContext]
SC --> C[Controller]
In the in-memory version, the “User store” is just a Map in memory. In the DB-backed version, the “User store” is a database, but for the rest of the security stack it is still a “User store” that can answer the question: “give me the user by login.”
The same applies to the access matrix. Your rules like “/api/public/** is accessible to everyone” and “/api/admin/** only for ADMIN” at the SecurityFilterChain level do not have to change just because the user is now stored somewhere other than memory. We are not rewriting mathematics just because we changed the calculator.
Boundary of responsibility: account storage
When you move from in-memory to persistence, the most valuable thing to preserve is the discipline of separation of concerns. The security layer should not know how the database is structured. It should know that there is a way to get the user, their password (as a hash), and their roles. That is all. If security starts to “understand tables,” it turns into a layered cake that is hard to maintain.
That is exactly why it is useful to mentally separate two levels. The first level is Spring Security and its contract: UserDetailsService returns UserDetails, and then DaoAuthenticationProvider does its job. The second level is your persistence: repository, database, seed data, migrations, and the other “earthly life” of data.
By the way, Spring Security directly shows that “moving to the database” is a normal, standard scenario: there is even a ready-made JdbcUserDetailsManager implementation that creates users in the database and reads them from there. In our educational project, we will most likely not go through a pure JDBC manager, but through our own account model; still, the idea is the same: you change the storage, not the entire security engine.
Hence the methodological boundary of this day: we are not turning the security course into a JPA course. We are not interested in “how to write the perfect migration,” but in “how to make users real project data, not strings in a config.”
Mini-examples: from hardcoded to abstraction
To make the transition look less like a leap into the unknown, it is useful to look at the “bridge” — a minimal abstraction that separates us from the specific way users are stored. And yes, this will now look almost banal. But in backend development, banal abstractions often turn out to be the most useful ones (and the most expensive if you do not introduce them in time).
First we have the in-memory approach, where the user appears directly in code:
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Bean
InMemoryUserDetailsManager users() {
// Here the admin is hardcoded into the configuration: this is acceptable only as a learning step.
// Important: withDefaultPasswordEncoder() is not a production approach.
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin") // Administrator login
.password("password") // Password (in demo mode)
.roles("ADMIN") // Role used later in access rules
.build();
// The manager stores users in process memory — everything will be forgotten on restart.
return new InMemoryUserDetailsManager(admin);
}
Again: this is fine as a temporary learning step, but withDefaultPasswordEncoder() is only “getting started,” not a model for a real project.
Let me clarify right away: this is not a new mandatory project layer and not a requirement to bring into the code an interface with exactly this name. What we need is only a transparent division of responsibility: security asks for an account by login, and the storage method can change without rewriting the whole mechanism.
Now let us look at the idea that “the user is data.” We need a point where the responsibility “find the account by login” lives:
import java.util.Optional;
public interface UserAccountStore {
// We look up an account by login: that is exactly what the authentication layer needs.
Optional<UserAccount> findByUsername(String username);
// Save/update an account: in the DB this will be INSERT/UPDATE, in memory — put in a Map.
void save(UserAccount account);
}
Even if you do not yet know what UserAccount will look like (we are deliberately not diving into the data model in this lecture), you already see an important thing: the account becomes a domain object, not a line in the configuration. And most importantly, you create a boundary that can later be implemented through a database without rewriting the whole application.
To make this idea completely tangible (and not be scared by the words “persistence layer”), you can even temporarily write a “pseudo-DB” on Map — purely as a learning transition:
import java.util.Map;
import java.util.Optional;
public class MapUserAccountStore implements UserAccountStore {
// In-memory “storage”: useful for the transition stage and for tests.
private final Map<String, UserAccount> accounts;
public Optional<UserAccount> findByUsername(String username) {
// Return Optional to show explicitly: the user may not exist.
return Optional.ofNullable(accounts.get(username));
}
}
Yes, this is still in-memory. But now in-memory is not “users in the config,” but a temporary storage implementation. In a real project, a JPA repository will happily take over this role, and a Map-based implementation is useful precisely as a learning bridge between hardcoded users and persistence. And that is a huge difference in mindset: you are preparing the project for the data source to be replaced while the rest of the system remains predictable.
And one last thing: notice that your access rules (for example, SecurityFilterChain) do not have to change at all in such a refactoring. That is why the move to persistence is not a “rewrite everything” day, but a “make users data” day.
4. Typical mistakes when moving to the DB
Mistake #1: trying to “force” in-memory by adding more users.
It seems that if you add ten more users to InMemoryUserDetailsManager, the problem will disappear. In reality, you are just increasing the size of the problem: instead of “a couple of test accounts,” you get a “mini user database hardcoded in Java,” which then becomes scary to touch.
Mistake #2: changing everything at once.
If in one day you change the user source, the URL rules, the roles, the login method, and the package structure, you lose the cause-and-effect chain. The correct focus for today: only the account source changes. The access matrix and the “who can do what” logic must remain the same, otherwise you will not understand what exactly broke.
Mistake #3: “just for a minute” forgetting about PasswordEncoder and putting a raw password into the seed/DB.
This is usually explained with the phrase “well, we will encode everything later anyway.” In security, that sounds roughly like “I am driving without a seat belt for now, but I will buckle up later.” If you once allow yourself to store a raw password, you have already built the wrong coordinate system.
Mistake #4: turning the move to the DB into a JPA epic.
Yes, the data layer matters, but today we are not training Hibernate; we are training design discipline: users are project data, and they must exist independently of the application code. If you drown in “perfect entities and migrations,” you may fail to notice that the responsibility boundary never appeared.
Mistake #5: mixing the security layer and storage details (tables/SQL/fields) into one pile.
The security layer should know the contract: “give me the user, their password hash, and their roles.” As soon as security starts to “understand tables,” it turns into a layered cake that is hard to test and even harder to change.
GO TO FULL VERSION