In-Memory & JDBC Authentication
Where user credentials live: in-memory users for tests, JdbcUserDetailsManager and its schema, writing a custom UserDetailsService, and seeding an initial administrator safely.
On this page
UserDetailsService is the seam between Spring Security and wherever your users actually live. The
interface is one method, and getting the surrounding details right — locked accounts, enumeration
resistance, initial seeding — is most of the work.
Key Takeaways
- In-memory users belong in tests and demos, never in a deployed application.
JdbcUserDetailsManagerimposes a schema that rarely fits a real user table.- A custom
UserDetailsServiceis short and is what most applications should write. - Throw the same exception whether the user is missing or the password is wrong.
- Seed the first administrator from a migration plus an environment variable.
In-memory
@Bean
@Profile("test")
UserDetailsService testUsers(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("alice")
.password(encoder.encode("password"))
.roles("USER")
.build(),
User.withUsername("admin")
.password(encoder.encode("password"))
.roles("USER", "ADMIN")
.build());
}The @Profile("test") is doing real work. An in-memory user bean without a profile guard is a
hard-coded credential that ships to production, and it happens often enough that scanners look for it.
Never use User.withDefaultPasswordEncoder(). It is deprecated, it stores {noop} passwords, and its
only purpose was making tutorials shorter.
JdbcUserDetailsManager
CREATE TABLE users (
username VARCHAR(50) NOT NULL PRIMARY KEY,
password VARCHAR(500) NOT NULL,
enabled BOOLEAN NOT NULL
);
CREATE TABLE authorities (
username VARCHAR(50) NOT NULL REFERENCES users (username),
authority VARCHAR(50) NOT NULL
);
CREATE UNIQUE INDEX ix_auth_username ON authorities (username, authority);@Bean
UserDetailsService jdbcUsers(DataSource dataSource) {
var manager = new JdbcUserDetailsManager(dataSource);
// Override the queries to match a real schema rather than adopting theirs.
manager.setUsersByUsernameQuery(
"SELECT email, password_hash, enabled FROM app_user WHERE lower(email) = lower(?)");
manager.setAuthoritiesByUsernameQuery(
"SELECT u.email, 'ROLE_' || r.name FROM app_user u "
+ "JOIN user_role ur ON ur.user_id = u.id "
+ "JOIN role r ON r.id = ur.role_id WHERE lower(u.email) = lower(?)");
return manager;
}This works and it is a compromise. The manager gives you createUser, updatePassword and
userExists for free, but it assumes a shape — username as primary key, one authorities table — that
few real applications have. Once you need a tenant column, an MFA secret or an email verification
flag, you are fighting it.
A custom implementation
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
private final UserRepository users;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) {
User user = users.findByEmailIgnoreCase(email)
// The same exception and message as a wrong password. Anything
// that distinguishes them is an account-enumeration oracle.
.orElseThrow(() -> new UsernameNotFoundException("bad credentials"));
return org.springframework.security.core.userdetails.User
.withUsername(user.email())
.password(user.passwordHash())
.authorities(authoritiesFor(user))
// Each of these maps to a distinct AuthenticationException that
// Spring throws before checking the password.
.accountLocked(user.isLocked())
.accountExpired(user.isExpired())
.credentialsExpired(user.passwordExpired())
.disabled(!user.isEnabled())
.build();
}
private List<GrantedAuthority> authoritiesFor(User user) {
var authorities = new ArrayList<GrantedAuthority>();
user.roles().forEach(role -> {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.name()));
// Fine-grained permissions alongside coarse roles.
role.permissions().forEach(p -> authorities.add(new SimpleGrantedAuthority(p.name())));
});
return authorities;
}
}Twenty lines, no imposed schema, and full control over what becomes an authority. This is what most applications should write.
For a richer principal — carrying tenant id, display name, MFA state — implement UserDetails on your
own class so @AuthenticationPrincipal gives controllers the object they actually want.
Those four account-state flags come with a caveat. Spring evaluates them before comparing the
password and throws a distinct exception for each, so a locked account fails differently from a wrong
one. If your failure handler surfaces that difference, you have rebuilt the enumeration oracle the next
section is about to close. LockedException, DisabledException and CredentialsExpiredException are
genuinely useful — to your audit log. Map all of them to a single user-facing message.
One startup wrinkle is common enough to name. The UserDetailsService needs a PasswordEncoder, the
security configuration needs the service, and Spring reports a cycle it cannot break. Declaring the
encoder in a small configuration class of its own — one that depends on nothing — resolves it, and
reads better than scattering @Lazy around until the context starts.
Enumeration resistance
The timing side is the part usually missed. DaoAuthenticationProvider handles it internally by
hashing a dummy password when the user is absent, which is why you should let it do the password
comparison rather than checking credentials yourself in the UserDetailsService.
The same applies elsewhere. A registration form that says "this email is already taken" and a password reset that says "no account found" both leak the same information as a login message would.
Seeding the first administrator
INSERT INTO app_user (id, email, password_hash, enabled, password_expired, created_at)
VALUES (
'00000000-0000-0000-0000-000000000001',
'${admin.email}',
'${admin.password_hash}', -- Flyway placeholder from an environment variable
true,
true, -- forces a change on first login
now()
)
ON CONFLICT (email) DO NOTHING;spring:
flyway:
placeholders:
admin.email: ${ADMIN_EMAIL}
admin.password_hash: ${ADMIN_PASSWORD_HASH}Two properties make this safe. The hash comes from the environment, so no credential exists in the
repository. And password_expired is true, so the first login forces a change — meaning even if
the bootstrap credential leaks, its useful life is one login.
Never commit a default password to a migration. Every deployment of the software then shares it, and those defaults are catalogued and scanned for.
Caching
@Bean
UserDetailsService cachingUserDetailsService(DatabaseUserDetailsService delegate,
CacheManager cacheManager) {
var caching = new CachingUserDetailsService(delegate);
caching.setUserCache(new SpringCacheBasedUserCache(cacheManager.getCache("users")));
return caching;
}Worth adding when loadUserByUsername runs on every request — with stateless JWT authentication and a
database lookup per call, it becomes your hottest query.
Keep the TTL short, a minute or two. A cached UserDetails means a revoked role or a disabled account
stays effective until the entry expires, which is a small but real window.
Note also what it does not cover: only successful lookups are cached, and only on the authentication path. Anything that loads a user straight through your repository — an admin screen, a scheduled job, a message listener — bypasses the cache entirely, so it is not a general-purpose read cache for the user table.
What to take away
Write a custom UserDetailsService rather than bending your schema to
JdbcUserDetailsManager. Let DaoAuthenticationProvider compare passwords so timing stays constant,
and keep every failure message identical. Seed the first administrator from an environment-supplied
hash with a forced change, and cache lookups briefly if they are on every request.
Frequently Asked Questions
Should I use JdbcUserDetailsManager or write my own?
How do I create the first administrator?
Why does my UserDetailsService cause a startup failure?
Related tutorials
- HTTP Basic & Form-Based AuthenticationThe two classic authentication mechanisms: when Basic is appropriate, configuring form login properly, custom success and failure handlers, logout, and account lockout that is not a DoS.
- JWT Authentication Deep DiveJWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.
- Password Management & EncodingStoring passwords properly: choosing between BCrypt, Argon2 and scrypt, DelegatingPasswordEncoder for zero-downtime migration, strength rules, and breached-password checks.
- OAuth 2.0 — The Complete GuideOAuth 2.0 without the confusion: the four actors, the grants that still matter, why PKCE is mandatory, refresh token rotation, and what OAuth 2.1 removed.