1. Password policy: meaning and boundaries
When you hear the words “password policy,” your brain usually draws the classic picture: “8 characters, uppercase, lowercase, a digit, a special symbol, and a drop of developer tears.” That really exists, but today we are talking about something else. Technical password policy is the set of rules for how the backend handles a password as a secret: where it appears, where it must disappear, what we store, how we verify it, what we log, and what is forbidden to log even “for just a second.”
In our course, policy is not a 40-page security document and not a corporate standard for the sake of a standard. It is a very practical discipline that makes code predictable: everyone on the team knows that a password cannot be compared through equals, cannot be written to a log, cannot be placed into a response DTO, and cannot be “slightly processed” and saved. Strangely enough, these very “small things” are what most often break security: not because someone is malicious, but because someone is tired, in a hurry, and “it’s only local anyway.”
The mechanics are already in place at this point: the password is encoded through a single PasswordEncoder, verified through matches(...), and shortcuts like withDefaultPasswordEncoder() and {noop} have already shown how easy it is to break this model. Now all that remains is to turn this from a pile of knowledge into project rules: how to name the field, where to keep only the hash, what is forbidden to log, and in which places the password is allowed to appear at all.
To avoid blurring the meaning, let us separate the terms. We are not discussing UX requirements for password complexity, and we are not building elaborate input rules — that is a separate product topic. Here we need a more grounded question: how do we handle rawPassword and passwordHash in code so that the project does not start leaking secrets?
2. A single PasswordEncoder in the application
In the early stages, a beginner naturally wants to write new BCryptPasswordEncoder() wherever “a password is needed right now.” That looks logical until the project grows to 5-10 classes, and in one place you have bcrypt with default parameters, in another a different encoder, and in the third — suddenly {noop}, because “it was easier to test that way.” And now you no longer have security, but a quest: “guess in which class the truth is stored.”
That is why policy establishes the principle: the application must have one central PasswordEncoder baseline, declared as a Spring bean. Then no component makes the decision “which encoder should I choose”; it simply receives it through DI. This lowers the chance of accidentally insecure code to almost zero, because the decision is moved into configuration and is visible immediately.
For such a baseline, PasswordEncoderFactories.createDelegatingPasswordEncoder() is enough. What matters here is not the factory call itself, but the discipline: policy relies on the already chosen encoder and does not allow one service to quietly live on bcrypt, another on {noop}, and a third to compare strings by hand.
In other words: one encoder, one storage format, and all the other password-related decisions are built on top of that single source of truth.
3. Storage: rawPassword → passwordHash
Almost all serious password problems start with rawPassword living longer than it should. It ends up in an object field, in a log, in an exception, in a memory dump, in some “later” structure. And then something leaks (not necessarily the database), and your secrets are suddenly exposed. Technical policy is precisely about making the life of the raw password as short as possible: it arrives at the input, immediately turns into a safe value for storage, and disappears from the business flow.
In our Secure Content Platform API project, this maps nicely to the UserAccount model, where the specification requires a passwordHash field. The name itself is already part of the policy: we literally forbid ourselves from thinking of it as “password.” It is not a password. It is a hash (encoded value) for verification. Such a small naming choice really reduces mistakes: when you see passwordHash, your hand is less likely to reach for equals.
It is useful to keep a simple password state map in mind:
| What it is | Example | Can it be stored long-term? | Can it be logged? |
|---|---|---|---|
| rawPassword | "qwerty123" | No | Never |
| passwordHash (encoded) | "{bcrypt}$2a$10$..." | Yes (that is what it is for) | Better not, either |
Yes, passwordHash is not plain text, but logging the hash also rarely brings value, and sometimes it helps an attacker with offline guessing. In this educational project, we will consider the principle “log neither raw nor hash” to be safe.
A good policy practice is to introduce a simple variable naming rule: wherever the password is still original, call it rawPassword, and where it is already stored, call it passwordHash or encodedPassword. Then even a tired person on Friday evening can read the code and still not confuse them.
The encode operation itself should not be long or “smart.” What matters is something else: it happens immediately at the input boundary, rather than being spread across controllers, repositories, and random util classes. As soon as the value survives this step, it is no longer password in code, but passwordHash.
To see how this will live in the domain model, imagine a minimal account object (temporarily, without JPA and without a database, just so we do not have to think in a vacuum):
package com.example.securecontent.auth;
public class UserAccount {
private final String username;
private final String passwordHash; // According to policy, we store only the hash, not the original password.
public UserAccount(String username, String passwordHash) {
this.username = username;
this.passwordHash = passwordHash;
}
}
If you are now catching yourself thinking “but where is the password?”, then congratulations: the policy works. There is no password as a value. There is only what can be stored.
4. Verification: only matches(...)
One of the most common beginner mistakes is trying to “make it symmetric”: if we encoded on save, then on login we should also do encode(input) and compare strings. That sounds logical until you remember what we discussed about adaptive one-way functions: they use salt and other parameters in such a way that the same password is not required to produce the same string on every encode. So “encode and compare” is not just a bad style, but a logical error: even correct passwords will start to “not match.”
Policy establishes the rule: password verification is always done through PasswordEncoder.matches(raw, stored). This is not an option and not “whatever is convenient.” It is the only correct API path within the Spring Security mental model.
At the policy level, the rule here is strict: no encode(input) + equals(...). Verification always happens through matches(rawPassword, passwordHash), because the stored value may include salt and algorithm parameters.
Notice that in such a check it is also useful to keep parameter names understandable. rawPassword first, passwordHash second — then both reading the code and debugging do not turn into a quest. As soon as you see equals between two “password-like” strings, the policy has already been violated.
Another practical policy nuance: do not normalize the password. Sometimes you want to do trim() or toLowerCase() so that “it is easier for the user.” But a password is not a city name. It must be compared exactly as a secret string. Any “normalization” creates unexpected effects and reduces the real entropy of the password. So the rule is simple: the backend treats the password as an opaque value and does not “improve” it.
5. Centralizing password handling
Even with the correct PasswordEncoder, it is easy to create chaos if half of the application knows how to encode and verify passwords “its own way.” The real power of policy is not that you know about bcrypt, but that you have moved password handling into one place. Then auditing is easier, the chance of error is lower, and the code is easier to read.
In our project, such a place is conveniently implemented as PasswordService and considered the “only door” for encode and matches. This is not magic, but discipline: fewer places in the code know about the password — fewer chances to accidentally log the raw value, encode it twice, or start comparing strings by hand.
The same rule applies to seeds and dev users. If you need a test account, the encoded value can be generated once locally with the same baseline encoder and then placed into a config or dev setting. Yes, the resulting string will be different every time — and that is exactly normal.
The point here is not a separate utility as such, but a habit: the project only contains the stored value, and the raw password does not become a permanent neighbor of the source code.
6. Logs and secret leaks
If a password had a personality, it would dream of ending up in a log. A log is a warm, cozy place: people read it, store it, forward it, archive it, and sometimes even attach it to a ticket. That is exactly why password policy must include a logging rule. And this rule sounds boring, but it saves projects: the password is never logged, even in the development profile, even “I will delete it later for sure,” even “it is just a test user.”
There is a tricky trap in modern Java code: records and Lombok. A record has a nice default toString() that prints all fields. That is convenient until one of the components turns out to be password. Then one harmless line log.info("request={}", request) becomes a leak.
Let us look at an example of dangerous code. It looks innocent, like a cat that just came to sit on the keyboard:
// Dangerous: if such a record is logged as a whole, the password leaks through toString().
public record LoginRequest(String username, String password) { }
And somewhere in the code:
// Bad: request.toString() will print all fields, including password.
log.info("Login request={}", request);
The password will end up in the log. Without encryption, without hashing, without anything. Just because toString decided to “help.”
Policy must explicitly forbid such logging. Instead, we log only the event context: who (username), what happened (success/failure), and, if necessary, technical request identifiers (for example, requestId from MDC — that is a separate topic). Example of a “good” log:
package com.example.securecontent.security.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class AuthLogging {
private static final Logger log = LoggerFactory.getLogger(AuthLogging.class);
public void loginFailed(String username) {
// According to policy: log only the context (username), but not the password or passwordHash.
log.warn("Login failed for username={}", username);
}
}
There is no password here. And there is no hash. And that is normal: for diagnostics, the fact and username are usually enough.
The same policy applies to errors and exceptions. You must not put the raw password into an exception message, and almost always you must not put the passwordHash there either. Even if you really want to “see what was compared” — that desire is in the category of “take a quick peek at the secret.” Secrets are not peeked at. If you need to debug matches, you debug through a test, not by logging secrets.
Another part of policy is what we return to the client. For any API, the rule is simple: responses never return either the raw password or the passwordHash. Even in admin endpoints. Even “just to make sure the user was created.” There are other tools for that: tests, admin screens, separate diagnostic mechanisms, but not returning the password in JSON.
To make this concrete, it helps to keep a short checklist of formats that can leak:
| Leak channel | What the error looks like | What to do according to policy |
|---|---|---|
| Logging a request DTO | request={LoginRequest(... password=...)} | Log only username/context |
| Exception | throw new ...("wrong password=" + rawPassword) | Never include the password in the message |
| Debug println | System.out.println(rawPassword) | Do not do it even in dev |
| API response | {"passwordHash":"{bcrypt}..."} | Do not include it in the response DTO |
Yes, the table is simple. But security bugs have exactly this nature: they do not look like “a hack,” they look like “a tiny convenient line for the developer.”
7. Shortcuts and seed data
Shortcuts like User.withDefaultPasswordEncoder() and {noop} are tempting precisely because they remove one “extra” step. That is why policy must do more than say “no”; it must provide an alternative. Otherwise the ban turns into a fight with reality: people will still write {noop} because “otherwise it takes too long.”
A healthy educational strategy is this: shortcuts can be shown as a demonstration, but the project policy must be different, and it must be implemented in code. In other words, in the main project code we do not use withDefaultPasswordEncoder() or {noop}. If you really need to create a test user, you either encode the password through PasswordEncoder and store the encoded string, or generate the encoded value once with the same baseline encoder. Yes, that is one step longer. But it is exactly the step that teaches you to think correctly.
Another detail that is often forgotten: DelegatingPasswordEncoder stores the prefix {id} directly in the string. That means encoded strings can be longer than you expect. And policy must include the simple rule: do not truncate them and do not “fit them into a small field”. Even if you are currently storing users “in memory,” you can accidentally write code that, for beauty’s sake, takes only the first 60 characters of the hash. Later, when you move to more realistic storage, it will blow up just as reliably as using equals instead of matches.
And yes: if you saw "{noop}password" in someone else’s tutorial and thought “oh, convenient,” remember that {noop} in our project policy is more of a red flag than a feature. It is allowed only as a teaching marker for “this is what no encoding looks like,” not as part of working code.
8. Policy in Secure Content Platform API
In Secure Content Platform API, it is convenient to keep this close together: the PasswordEncoder configuration is in security.config, password handling is in security.password, and business code and domain entities should not “know” about algorithms or about what bcrypt is.
You can draw this as a small flow diagram to really “pin” the idea in your head:
flowchart LR
A["Input boundary
rawPassword"] --> B["PasswordService"]
B --> C["PasswordEncoder
Delegating..."]
C --> D["passwordHash
{id}..."]
D --> E["Account storage
UserAccount.passwordHash"]
This framework is already enough: one baseline encoder in security.config, one place for encode and matches in security.password, and passwordHash in the account model. The policy is not about the number of classes, but about the fact that the entire project uses the same rule.
And here it is important to remove one seemingly contradictory point. Even if a user temporarily lives in memory or in another simple user store, the password there is still an encoded value. The source of the user changes, not what is stored in the password or passwordHash field. So the same password baseline can comfortably survive both a dev user and a more realistic account model.
9. Typical mistakes in technical password policy
At the end of the day, it is useful to gather the most common pitfalls into one block. Not to scare anyone, but so that you learn to recognize these mistakes by smell: you see a line of code and immediately feel “this smells like a leak” or “this smells like a bad check.” That is a skill that makes a developer calmer, because less time is spent on mysterious bugs and unpleasant surprises.
Mistake #1: storing or passing the password as a “normal String field”.
A beginner creates UserAccount and adds password to it because “it is as convenient as username.” This breaks the model at the root: the password starts living for a long time, participating in toString(), entering serialization and debugging. The correct approach is to store only passwordHash, and keep the raw password exclusively at the input boundary and encode it immediately.
Mistake #2: verifying the password with encode + equals.
This is the classic logical trap: “if encode makes a string, then let us compare strings.” But for proper algorithms, the password is not required to produce the same result every time you encode it, so comparing two encoded strings turns password verification into a lottery where you almost always lose. According to policy, verification is always done only through matches(raw, stored).
Mistake #3: creating new BCryptPasswordEncoder() in different places of the application.
At first it looks “fast and clear,” and then you discover that part of the code uses one encoder, part uses another, and somewhere {noop} accidentally appeared. In the end, it is impossible to answer the question “what is our password strategy?” According to policy, the encoder must be a single bean, and everyone uses it through DI.
Mistake #4: logging request objects as a whole (especially records) and accidentally leaking the password into logs.
log.info("request={}", request) is a convenient habit that turns into a leak in the presence of a password. A record toString() includes all components, and the password will end up in the logs faster than you can say “oops.” According to policy, we log only the context: username, event fact, technical identifiers, but not secrets.
Mistake #5: “well, it is just dev / a test project, so {noop} is fine.”
That is how bad habits are born. Today {noop} is “only local,” tomorrow it lands in the main branch “for five minutes,” and the day after that someone forgets to remove it. In an educational project we can show {noop} as a marker, but the project policy should remain production-minded: encoded passwords, an explicit PasswordEncoder, and no raw secrets in the repository.
GO TO FULL VERSION