Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner5 min readUpdated
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.
  • JdbcUserDetailsManager imposes a schema that rarely fits a real user table.
  • A custom UserDetailsService is 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

InMemoryUsers.java
@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

V1__spring_security_schema.sql
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);
JdbcUsers.java
@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

DatabaseUserDetailsService.java
@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

Both the message and the response time must be identical, or the difference tells an attacker which addresses are registered.

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

V2__seed_admin.sql
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;
application.yml
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

CachedUserDetailsService.java
@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?
Write your own UserDetailsService for anything beyond a prototype. The built-in manager imposes a schema that rarely matches a real user table, and you will want fields it does not have — tenant, MFA secret, last login, verification state. A custom implementation is about twenty lines.
How do I create the first administrator?
A migration that inserts the row with a password hash supplied by an environment variable, plus a forced password change on first login. Never hard-code a default password in a migration — those end up on the internet, and every deployment of your software shares it.
Why does my UserDetailsService cause a startup failure?
Usually a circular dependency: the service needs a PasswordEncoder, the security configuration needs the service, and something in between closes the loop. Declare the PasswordEncoder bean as static or move it to a separate configuration class that does not depend on security.

Related tutorials