CodeGym /Courses /Spring Security /Password cannot be stored in plain text

Password cannot be stored in plain text

Spring Security
Level 6, Lesson 0
Available

1. A password is a secret, not "just another field in JSON"

If before this we thought about security as access rules for endpoints, today we are switching to another layer: the credentials themselves. A password is not "just a value the user entered". It is a secret that grants access. A mistake in handling a secret nullifies any nice hasRole() checks and even the neatest SecurityFilterChain.

Let's start with a simple idea: many user data items can be "read" and can even leak unpleasantly, but not critically. For example, a display name or bio from a profile — yes, that's a pity, yes, it's unpleasant, but the world does not end. A password is a different category. It is not about "information", it is about "access". That is why a password cannot be stored as an ordinary field in an object or as a string in a config: any copy of that string automatically becomes the key to the account.

Let's look at an example that technically compiles, looks "logical", and is therefore dangerous:

package com.example.securecontent.security.demo;

class CredentialsStore {
    // In a real application, a password should never "just sit there" as a string in object state.
    String username = "anna";

    // Bad: the password is stored in plain text (raw), and any leak of this string = access to the account.
    String storedPassword = "qwerty123";
}

The problem here is not even that this is a "training" class. The problem is the model: a password as an ordinary string that is "lying around" somewhere. Once you allow yourself the thought "let it sit there", compromises start: first in the code, then in application.yml, then in test data, then "let's print it to the log to check", and suddenly you have built a system where the password travels around the world like a tourist with an unlocked suitcase.

Another important nuance is psychological. In a normal CRUD project, we are used to everything being stored in the database: email, phone number, date of birth, status, and so on. A password is the exception. It must not become part of long-lived state "as is". And this needs to be accepted as a principle even before technical details like hashing and PasswordEncoder.

2. The cost of a leak: plain text breaks everything

In this part, it is important not to fall into paranoia, but also not to slide into "we are a small project, who would care about us". In reality, leaks happen less often because of a spy in a black cloak and more often because of ordinary engineering life: backups, dumps, logs, bug reports, screenshots, "send me the SQL from local", "let's quickly show the demo at the meeting". A plain-text password makes each such everyday situation a potential disaster.

If a password is stored in plain text, compromise looks like this: someone got access to the storage location — and that's it, the password is already "stolen". Not "might have been stolen", not "if you try hard enough", but simply stolen, because it is the same string the user enters at login. The system has nothing left to defend with: the attacker does not need to "break" anything further, they simply pass authentication as a legitimate user.

And here a second effect appears, one that beginners often think is "not about programming". People massively reuse passwords. If you store passwords in plain text, then a leak from your training (or internal) service can become an entry point into someone else's user accounts: email, social networks, banking apps. This is one of the reasons why an "internal service" does not give you the moral right to store a password as a string. It is not only about your system, it is about the user.

In the end-to-end Secure Content Platform API project, we are building a platform with roles, private drafts, and admin operations. Imagine you have an admin account (even a test one), and its password is stored in plain text. At some point, that password will end up in a log or a dump. After that, anyone who sees that string can become an admin, and then it no longer matters how carefully you protected /api/admin/**.

3. Leaks from logs, seed data, and dumps

The most insidious part is that a password rarely "leaks from the database" like in a movie. More often it spreads through the infrastructure unnoticed. And that is exactly why "do not store passwords in plain text" means not only "do not put it in a table", but also do not let the raw password live longer than a couple of seconds of request processing and end up in any side channels.

Below is a table of typical places where passwords appear more often than you'd like. This is not a list of "horror stories", but ordinary development and operations scenarios:

Where the password appears What it looks like in code/process Why it is dangerous
Application logs System.out.println(...),
log.info(...)
with the password
Logs live a long time, often go to a centralized collector, and are accessible to many
Seed data and demo users "Let's add
admin/admin
to the repository"
This ends up in Git forever (even if you delete it later)
Memory dumps / heap dump The password was in
String
and ended up in a dump
Dumps are often sent "for analysis", and then everything ends up there
Exceptions and stacktraces throw new ...("password=" + rawPassword) The error goes to logs/monitoring/tickets together with the secret
Debugging and the debugger Set a breakpoint, took a photo of the screen, sent a screenshot Very human scenario. And therefore especially common
Tracing/observability Request bodies are logged automatically If the request body is logged, the password goes to monitoring

The simplest, but most illustrative example is debug output:

String rawPassword = "qwerty123"; // raw password: exists only "on input", not for storage and not for logs

// Bad: even "temporary" output makes the secret part of the logs (and often for a long time).
System.out.println("password=" + rawPassword);

It is funny that this code is often written "for two minutes". But logs usually live much longer than two minutes. They end up in CI, in container logs, in ELK/Graylog/Splunk, in archives, and after that nobody remembers it was "temporary".

Another pain point is automatic logging of objects. In Java this looks especially innocent because toString() "just shows everything nicely". And this is where it is easy to shoot yourself in the foot with a record:

// A DTO with a password is dangerous by itself: it must not be logged as a whole.
public record LoginRequest(String username, String password) {}

class Demo {
    public static void main(String[] args) {
        LoginRequest req = new LoginRequest("anna", "qwerty123");

        // Bad: by default, record prints ALL fields in toString(), including the password.
        // The same thing will happen if you do log.info("request={}", req).
        System.out.println(req); // LoginRequest[username=anna, password=qwerty123]
    }
}

The comment in the code is not a joke: record really prints all fields. So if somewhere you do log.info("request={}", req), the password goes to the log. And this can happen even without any malicious intent on your part: sometimes "smart" loggers or filters log the request body as a whole.

And one more nuance for those who like to say "it's just a string". In Java, String is immutable. If you put a password into a String, you can no longer "zero it out in memory". It will live until GC collects it, and it can end up in dumps. We are not going into the deep track of secret memory management today, but it is useful to know: storing a password as a string "somewhere for a long time" is a bad idea even at the JVM reality level.

4. Perfect URL rules do not save you when the password leaks

After Day 5, you may get a very pleasant sense of control: we wrote SecurityFilterChain, we have permitAll() for the public zone, authenticated() for the private one, hasRole() for the admin area, everything is beautiful. And this is indeed important. But today we need to see an unpleasant truth: protection by URL rules answers the question "may this user get here", while password storage answers the question "who became this user in the first place".

If the password leaked, the attacker will not "bypass" your access rules. They will simply pass authentication as the user whose password they know. And then all your mechanisms will work against you: SecurityContext will be populated, Authentication will look legitimate, logs will show "an ordinary user request", not an attack. In other words, a password leak turns the attacker into a "normal user" from the system's point of view.

At this point it is especially important to remember from previous days that Spring Security does not "check access in the controller". It makes decisions in filters and based on Authentication. If credentials are compromised, the system will honestly and disciplinarily serve the attacker. Not because Spring Security is bad, but because it cannot read minds and distinguish the "real" user from the one who learned the password.

There is also a second effect, less obvious but very practical: if a password is stored in plain text, then anyone inside the team can see it. Any employee with access to the database, logs, or repository effectively gets the ability to "be the user". This breaks not only security, but also basic accountability. You cannot investigate incidents, you cannot trust audit logs, you cannot even honestly say "the user did this", because it could have been done by anyone who saw the password.

So the good news is: we still need SecurityFilterChain. The bad news: without the right password storage model, it remains "a fence around a house whose key is lying outside".

5. A bad model: save the string and compare with equals()

Now we will deliberately "break" one familiar programming reflex: "if we need to check whether values match, let's compare them". For many tasks this is fine: compare email, compare slug, compare status. But a password is not such an entity. As soon as you write inputPassword.equals(storedPassword), you automatically admit that storedPassword is the same password the user enters. And that means it is stored in plain text.

Here is an example that looks like a training one, but actually demonstrates the wrong architecture:

String inputPassword = "qwerty123";  // raw password entered by the user
String storedPassword = "qwerty123"; // bad: this means a raw password is stored in the storage

// Bad: "equals" implies comparing two identical secrets.
// In the correct model, you compare the raw password with the STORED value via PasswordEncoder.matches(...).
boolean authenticated = inputPassword.equals(storedPassword);

System.out.println(authenticated); // true

Yes, this works. And that is the trap. This code "works" only because you have already lost: you have already decided to store the password in raw form.

The correct model looks different: you store not the password, but some prepared string (we will later call it passwordHash or encodedPassword), and verification is not a comparison of two identical strings, but a special procedure that matches the "entered password" against the "saved value". In Spring Security, this is done by PasswordEncoder.matches(...), not by a manual equals. We are not going into PasswordEncoder details yet, but it is important for your brain to switch now: a password is not a "value to compare", but a secret to verify.

And this is where an interesting point appears, which is useful to understand in advance so that you are not surprised tomorrow. In a normal system, the same password entered by two different users should not turn into the same "stored string". If you expect "same input → same storage", you are still thinking of a password as an ordinary field. But a password is not an ordinary field.

6. A password storage model without cryptography

We are very tempted to jump straight into algorithm names, but methodologically it is better to pause and fix the architectural principle in simple words. The idea is this: the backend must not store "what the user entered". It must store a value that allows the password to be verified, but does not allow it to be "restored back". The system must be able to answer "matched/did not match", but must not have a "show password" button.

From the life-cycle point of view, it looks like this. When a user is created (or the password is changed), the raw password comes into the application as input, then it is converted as quickly as possible into a "storage value", and the raw version stops being part of the further process. At login, the raw password comes in again as input, but instead of comparing strings the system performs a check through a special mechanism, using the saved value.

It is convenient to keep this idea in your head as a small flowchart:

flowchart TD
    %% Important: there is no "retrieve the password back" step on the diagram — the system must be able to verify, not restore.
    A["User entered a password (raw)"] --> B["The application prepared a value for storage"]
    B --> C["We store only the prepared value (not raw)"]

    D["User entered a password at login (raw)"] --> E["We verify raw against the saved value"]
    E --> F["Matched? yes/no"]

Notice: there is no "retrieve the password back" step anywhere on the diagram. And that is normal. If at some point you catch yourself wanting to "decrypt the password and look at it", then you are mentally heading in the wrong direction.

How does this relate to Spring Security at the application level? In the usual username/password model, UserDetailsService returns UserDetails with the stored password value (not raw), and DaoAuthenticationProvider uses PasswordEncoder for verification. In other words, Spring Security out of the box expects you to store not the "user's password", but "what can be used to verify the input".

For now, it is enough to accept the principle itself: the system stores not the password, but the value used for verification. From there, two engineering questions immediately arise: why a password needs a one-way, not two-way, model, and how this verification is assembled through PasswordEncoder.

7. Rules for Secure Content Platform API

From this point on, we start turning the principle into an "engineering habit". Even in the simplest user store, these rules should already work: a few decisions can be fixed right now and you will avoid the most common pitfalls.

The first rule is very simple: if you create an account model anywhere, the field should be called not password, but at least passwordHash or encodedPassword. The name is not cosmetic. It directly affects how you will think about this value. password encourages "let's print it, let's compare it". passwordHash immediately reminds you: this is not the original.

The second rule: log the event and context, but not the secret. For example, "failed login attempt username=anna" is a normal log. "failed login attempt username=anna password=qwerty123" is your future post about "how we accidentally caused a leak".

Here is an example of careful logging without secrets:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

    void loginFailed(String username) {
        // Ok: log only the user identifier (context), not the secret (password).
        log.warn("Login failed for username={}", username);

        // Important: never add the raw password here, even "for a minute to debug".
    }
}

The third rule is about DTOs and toString(). If you have an object that carries a password (for example, a request DTO), then you must not log it as a whole. Even if you "just looked at it in the debugger", it is very easy to later accidentally do log.info("{}", request) and get the password into logs. Therefore, a safe habit is to log separate fields that are not secrets, most often username/email and, possibly, IP/UA (but even there you need moderation).

And finally, the fourth rule is about "training conveniences". Spring Boot can generate a password and print it to the console at startup. This is useful for the first run and for observing secure-by-default behavior. But this is exactly a training or diagnostic mechanism. You must not turn it into a habit of "passwords in logs are normal". It is normal only as a temporary bridge so that you can actually log in and see the system behavior.

8. Typical mistakes when handling passwords

This topic seems too obvious right up until the first time you find a password in the logs. And then the classic acceptance stages begin: denial, anger, bargaining, depression, looking for who "committed that", and finally realizing that "I added that log five minutes ago". So it is better to know the most common mistakes in advance.

Mistake #1: store the password as a password field and think that later we will "switch to a hash".
In practice, "later" often never comes. Code with raw passwords has time to grow logging, tests, seed data, documentation, and become a habit. It is much better to name the field passwordHash right away and accept the rule: the raw password exists only on input and is not stored.

Mistake #2: log the password "literally for a minute, to understand why it does not work".
Logs are not a temporary notebook. They often go to centralized storage, end up in CI, are stored for months, and are accessible to more people than the source code. Even a single password=... line in logs may be enough to compromise an account, especially if the password is reused.

Mistake #3: log the DTO as a whole and not notice that toString() prints secrets.
Records and many auto-generated toString() methods print all fields. If a DTO contains a password, then log.info("{}", dto) prints the password. The danger is that this looks "polite" and "nice", so it is easy not to notice. The habit should be the opposite: log only safe fields, most often username.

Mistake #4: think that "we are an internal service / training project", so raw passwords are fine.
Training projects often live longer and travel more than production. They spread across laptops, chats, repositories, demos, and very easily end up in the wrong hands. Plus, a password is always a risk for the user because of reuse. The internal nature of the project does not cancel basic hygiene.

Mistake #5: hope that Spring Security "will protect everything by itself", even if the password is stored in plain text.
Spring Security protects access at the request level and builds the SecurityContext based on Authentication. But if the attacker got the password, they become a "normal" user from the system's point of view. That is why password storage is the foundation on which all other mechanisms only make sense to build.

1
Task
Spring Security, level 6, lesson 0
Locked
Safe Message for a Failed Login
Safe Message for a Failed Login
1
Task
Spring Security, level 6, lesson 0
Locked
Account model without raw password in the object state
Account model without raw password in the object state
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION