Spring Security Users, Passwords and PasswordEncoder

Introduction

This tutorial is about the users of a Spring Security application and, above all, their passwords. We will see why a password must never be stored as it is, or even with an ordinary hash, and what a slow, salted hash such as bcrypt does instead. We will hash and check passwords with BCryptPasswordEncoder, then use the DelegatingPasswordEncoder that Spring Security recommends, with its {id} prefixes. We will then move the users from memory into a database with JdbcUserDetailsManager, register a new user through a REST endpoint, and watch a password stored in an old format being upgraded automatically at login.

Users in Spring Security

In the architecture tutorialClick to read tutorial: Spring Security Architecture: Filter Chain Explained we saw that the DaoAuthenticationProvider loads a user through a UserDetailsService and checks the password with a PasswordEncoder. These are the pieces you choose -
  • UserDetails: one user as Spring Security sees it - username, stored password, authorities, and flags for enabled, locked and expired accounts. User.withUsername(...) builds one.
  • UserDetailsService: one method, loadUserByUsername(String). Spring Security only needs to read users.
  • UserDetailsManager: adds createUser, updateUser, deleteUser, changePassword and userExists. Spring Security ships two: InMemoryUserDetailsManager, used in the previous tutorials, and JdbcUserDetailsManager, used here. With JPA, you would write your own UserDetailsService over a user entity.
  • PasswordEncoder: encode(raw) when a password is set, matches(raw, stored) when a user logs in.

How passwords must be stored

Databases leak: through backups, SQL injection, a stolen laptop, or an employee with too much access. The way passwords are stored decides what an attacker gets from a stolen table. Plain text gives away every account, and since people reuse passwords, their accounts on other sites too. A fast hash such as SHA-256 is little better: identical passwords produce identical hashes, and modern hardware can test billions of guesses per second against them.

A password hashing function is built for exactly this job. It is one-way: there is no way back from the hash to the password, only checking a guess. It adds a random salt to every password before hashing, so two users with the same password get different stored values. And it is deliberately slow, with a cost factor that can be raised as computers get faster. bcrypt, the default in Spring Security, does all three - How bcrypt stores a password - the stored value holds the encoder id, version, cost, a random salt and the hash; registering hashes the password with a new salt, logging in hashes the typed password with the stored salt and compares

Hashing passwords in code

The first program uses the encoders directly, without Spring Boot, to show what they produce -
PasswordDemo.java - BCrypt and the DelegatingPasswordEncoder
//PasswordDemo.java
package com.javabrahman.security.passwords;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

public class PasswordDemo {

  public static void main(String[] args) {
    System.out.println("== BCryptPasswordEncoder");
    PasswordEncoder bcrypt = new BCryptPasswordEncoder();
    String first = bcrypt.encode("secret");
    String second = bcrypt.encode("secret");
    System.out.println("hash 1: " + first);
    System.out.println("hash 2: " + second);
    System.out.println("same hash twice?   " + first.equals(second));
    System.out.println("matches hash 1?    " + bcrypt.matches("secret", first));
    System.out.println("matches hash 2?    " + bcrypt.matches("secret", second));
    System.out.println("wrong password?    " + bcrypt.matches("Secret", first));
    System.out.println("parts of hash 1:   version " + first.substring(1, 3) + ", cost " + first.substring(4, 6)
        + ", salt " + first.substring(7, 29) + ", hash " + first.substring(29));

    System.out.println("== DelegatingPasswordEncoder");
    PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
    String stored = encoder.encode("secret");
    System.out.println("encode:            " + stored);
    System.out.println("matches {bcrypt}:  " + encoder.matches("secret", stored));
    System.out.println("matches {noop}:    " + encoder.matches("secret", "{noop}secret"));
    System.out.println("upgrade {noop}?    " + encoder.upgradeEncoding("{noop}secret"));
    System.out.println("upgrade {bcrypt}?  " + encoder.upgradeEncoding(stored));
    try {
      encoder.matches("secret", "secret");   // stored without an {id} prefix
    } catch (IllegalArgumentException e) {
      System.out.println("no prefix:         " + e.getMessage());
    }
  }
}
 OUTPUT - hashing and checking passwords
== BCryptPasswordEncoder
hash 1: $2a$10$LMg6DGkE2EBwNsWI3h5hxO4ioj38prLaLTC0aO12sj61AM5iCL7.2
hash 2: $2a$10$8LxtNvUc9H.folhHmM3yIOrw8Nbys4gpXF/KXbVS.R3zA345nYEtq
same hash twice?   false
matches hash 1?    true
matches hash 2?    true
wrong password?    false
parts of hash 1:   version 2a, cost 10, salt LMg6DGkE2EBwNsWI3h5hxO, hash 4ioj38prLaLTC0aO12sj61AM5iCL7.2
== DelegatingPasswordEncoder
encode:            {bcrypt}$2a$10$zxGJTISTAB0jOdN4lyUrvuMsTcirQ4sJQIizzNJt0Th6sCqwEf1um
matches {bcrypt}:  true
matches {noop}:    true
upgrade {noop}?    true
upgrade {bcrypt}?  false
no prefix:         Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `DelegatingPasswordEncoder`.
(The salts are random, so each run prints different hashes; the true and false results are always the same.)

Explanation of the output

  • Same password, two hashes: encoding "secret" twice gave two different values, and equals() says false. Each call chose a new salt. Yet matches("secret", ...) is true for both: matches() reads the salt and cost out of the stored value, hashes the typed password the same way, and compares. That is also why you can never compare passwords by encoding the typed one and checking equals().
  • Case matters: "Secret" did not match.
  • The parts of a bcrypt hash: version 2a, cost 10, which means 210 = 1,024 rounds of the algorithm's expensive setup, a 22-character salt, and the 31-character hash itself. The salt is not secret; it only has to be unique. new BCryptPasswordEncoder(12) would use cost 12, four times slower than 10. Pick the highest cost your login rate allows.
  • The DelegatingPasswordEncoder, from PasswordEncoderFactories.createDelegatingPasswordEncoder(), stored the same kind of bcrypt hash with a {bcrypt} prefix in front. It matched both that value and {noop}secret, a plain-text password marked with the {noop} prefix.
  • upgradeEncoding() is true for the {noop} value and false for the current bcrypt one: the encoder can tell which stored passwords are out of date.
  • A stored password without any prefix is refused with an IllegalArgumentException: "each password must have a password encoding prefix".

Why a DelegatingPasswordEncoder

Password storage has to change over time. MD5 and SHA-1 were once common, bcrypt replaced them, and Argon2 may replace bcrypt one day. An application that has stored passwords for ten years holds several formats at once, and cannot convert them, because it does not know the passwords - only users typing them can. The {id} prefix makes every stored password say which encoder made it - DelegatingPasswordEncoder - the id prefix of each stored password selects bcrypt, pbkdf2, argon2 or noop for matching, while encode always uses the current default The factory knows the ids bcrypt, pbkdf2, scrypt, argon2, noop and several older ones kept for migrations. Scrypt and Argon2 also need the Bouncy Castle library on the classpath. Declaring this encoder as a PasswordEncoder bean is the usual setup.

Users in a database

JdbcUserDetailsManager stores users in two tables, users and authorities, and reads them with the SQL queries in JdbcDaoImpl, which can be changed to fit an existing schema. Spring Security ships a script for its default tables, org/springframework/security/core/userdetails/jdbc/users.ddl. Here we create them in schema.sql, which Spring Boot runs at startup for the embedded H2 database -
schema.sql - the tables Spring Security's JDBC user store expects
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);
UserConfig.java - users in the database, and the password encoder
//UserConfig.java
package com.javabrahman.security.passwords;

import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class UserConfig {

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  @Bean
  JdbcUserDetailsManager users(DataSource dataSource) {
    JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
    users.setEnableUpdatePassword(true);   // let Spring Security re-hash outdated passwords at login
    return users;
  }

  @Bean
  SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/register", "/debug/passwords").permitAll()
            .anyRequest().authenticated())
        .httpBasic(withDefaults())
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    return http.build();
  }
}
With a UserDetailsService bean and a PasswordEncoder bean in the context, Spring Security builds the DaoAuthenticationProvider from them by itself. JdbcUserDetailsManager also implements UserDetailsPasswordService, the interface used to save an upgraded password. But it only saves upgrades when setEnableUpdatePassword(true) is called. Without that line, an earlier run of this application logged the legacy user in successfully and left the plain-text password in the table.

The controller registers users, shows the logged-in user, and - for this demonstration only - shows the stored passwords -
AccountController.java - registering users, and a look at what is stored
//AccountController.java
package com.javabrahman.security.passwords;

import java.security.Principal;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AccountController {
  private final UserDetailsManager users;
  private final PasswordEncoder encoder;
  private final JdbcClient jdbc;

  public AccountController(UserDetailsManager users, PasswordEncoder encoder, JdbcClient jdbc) {
    this.users = users;
    this.encoder = encoder;
    this.jdbc = jdbc;
  }

  @PostMapping("/register")
  public ResponseEntity<String> register(@RequestParam String username, @RequestParam String password) {
    if (users.userExists(username)) {
      return ResponseEntity.status(409).body("username taken");
    }
    users.createUser(User.withUsername(username).password(encoder.encode(password)).roles("USER").build());
    return ResponseEntity.status(201).body("registered " + username);
  }

  @GetMapping("/api/me")
  public String me(Principal principal) {
    return "logged in as " + principal.getName();
  }

  // For this demo only: shows what the database holds. Never expose password hashes in a real application.
  @GetMapping("/debug/passwords")
  public List<String> passwords() {
    return jdbc.sql("select username, password from users order by username")
        .query((rs, n) -> rs.getString(1) + " = " + rs.getString(2))
        .list();
  }
}
At startup, the application creates ann, with a bcrypt-hashed password, and "old", a user imported from an earlier system that stored passwords in plain text, marked {noop} -
UsersApp.java - starting with one current user and one legacy user
//UsersApp.java
package com.javabrahman.security.passwords;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;

@SpringBootApplication
public class UsersApp {

  @Bean
  CommandLineRunner seed(UserDetailsManager users, PasswordEncoder encoder) {
    return args -> {
      users.createUser(User.withUsername("ann").password(encoder.encode("ann-pass")).roles("USER").build());
      // a user from an old system, whose password was stored in plain text
      users.createUser(User.withUsername("old").password("{noop}old-pass").roles("USER").build());
    };
  }

  public static void main(String[] args) {
    SpringApplication.run(UsersApp.class, args);
  }
}
 OUTPUT of the HTTP requests
> curl -i http://localhost:8080/debug/passwords
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 101
Content-Type: application/json
Expires: 0

["ann = {bcrypt}$2a$10$Hjvbgoq2PH/RmWsRwvDZout/JYdVz.cfK1I47bfm15WybVkZIRoKW","old = {noop}old-pass"]

> curl -i -X POST http://localhost:8080/register -H "Content-Type: application/x-www-form-urlencoded" -d 'username=bob&password=bob-pass'
HTTP/1.1 201
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 14
Content-Type: text/plain; charset=UTF-8
Expires: 0

registered bob

> curl -i -X POST http://localhost:8080/register -H "Content-Type: application/x-www-form-urlencoded" -d 'username=bob&password=other'
HTTP/1.1 409
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 14
Content-Type: text/plain; charset=UTF-8
Expires: 0

username taken

> curl -i http://localhost:8080/api/me -H "Authorization: Basic Ym9iOmJvYi1wYXNz"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Expires: 0

logged in as bob

> curl -i http://localhost:8080/api/me -H "Authorization: Basic Ym9iOndyb25n"
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Expires: 0

> curl -i http://localhost:8080/api/me -H "Authorization: Basic b2xkOm9sZC1wYXNz"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Expires: 0

logged in as old

> curl -i http://localhost:8080/debug/passwords
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 232
Content-Type: application/json
Expires: 0

["ann = {bcrypt}$2a$10$Hjvbgoq2PH/RmWsRwvDZout/JYdVz.cfK1I47bfm15WybVkZIRoKW","bob = {bcrypt}$2a$10$/H4hodzVr8nJvN2xopsYzuKsfPIIbmsl0i2GSnkhx9gSx/dUTvSDi","old = {bcrypt}$2a$10$jVEa0Ty08Hl13m5RYJkpeuMit4BdMc.UkQA3wZWJtLJ4BdsfGT/ky"]

Explanation of the responses

  • The stored passwords at startup: ann's is a {bcrypt} hash, old's is {noop}old-pass, readable by anyone with access to the table.
  • Registering bob: 201. The controller encoded "bob-pass" before handing the user to createUser(); the manager stores whatever it is given, so encoding is the application's job. Registering bob again: 409, "username taken".
  • Logging in as bob with the right password: 200; with a wrong one: 401. The user now comes from the database, through the same DaoAuthenticationProvider as before.
  • Logging in as old: 200. The {noop} password still works, because the DelegatingPasswordEncoder matched it with the no-op encoder.
  • The stored passwords at the end: bob has a {bcrypt} hash, and so does old. After old's successful login, the provider saw that upgradeEncoding() was true, encoded the password old had just typed with bcrypt, and saved it through updatePassword(). The plain-text password is gone, without any action by the user. This is how an application moves all its users to a stronger format over time: each one is upgraded at their next login.

Password rules worth following

  • Never log, return or email a password, not even a hash. The /debug/passwords endpoint exists only to make this tutorial's point visible.
  • Use the DelegatingPasswordEncoder, and never {noop} outside examples and tests.
  • Prefer long passphrases over complexity rules: current guidance (NIST SP 800-63B) favours a minimum length and a check against known breached passwords over rules such as "one digit and one symbol". Spring Security can check new passwords against the "Have I Been Pwned" range API with HaveIBeenPwnedRestApiPasswordChecker, a CompromisedPasswordChecker.
  • Answer the same way for unknown users and wrong passwords, as Spring Security does with its 401, so that attackers cannot find out which usernames exist.
  • Limit login attempts, for example by locking an account for a while after repeated failures, since even bcrypt only slows guessing down.

Summary

In this tutorial we saw how Spring Security models users with UserDetails, UserDetailsService and UserDetailsManager, and why passwords must be stored as slow, salted, one-way hashes. BCryptPasswordEncoder produced a different hash for the same password on every call, each one still matching, and we took a bcrypt hash apart into version, cost, salt and hash. The DelegatingPasswordEncoder added an {id} prefix, matched old formats as well as new ones, and reported which ones need an upgrade. With JdbcUserDetailsManager, users moved into database tables, a REST endpoint registered new users with encoded passwords, and, once setEnableUpdatePassword(true) was set, a plain-text legacy password was replaced by a bcrypt hash at the user's next login. The next tutorial logs users in with a form, and keeps them logged in with a session: Form login and session authenticationClick to read tutorial: Spring Security Form Login and Session Authentication.