Skip to content
CalliCoder

Spring Boot, Spring Security and JWT with React — Part 2

Published Updated Spring Boot 13 min read Part of Spring Boot JWT Authentication with React

Email and password authentication: signup and signin endpoints, BCrypt, a UserDetailsService over the entities from part 1, and what a React client does with the token once it has one.

Part 1 built the entities, repositories and role seeding, and left every endpoint behind Spring Security’s generated Basic password. This part replaces that with registration and login.

The filter-chain plumbing (SecurityFilterChain, TokenProvider, the per-request authentication filter) is the same machinery covered in detail in OAuth2 social login part 2, so this focuses on what is different: the credential endpoints, password handling, and the client side.

Written against Spring Boot 3.2, Spring Security 6.2 and Java 17.

The security configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;
    private final TokenAuthenticationFilter tokenFilter;

    // constructor omitted

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .cors(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(e -> e.authenticationEntryPoint(new RestAuthenticationEntryPoint()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/polls/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

The AuthenticationManager bean is the one addition over the OAuth2 configuration, and it is needed because this flow authenticates credentials explicitly rather than delegating to a provider. In Spring Security 6 you obtain it from AuthenticationConfiguration; the older authenticationManagerBean() override no longer exists.

BCryptPasswordEncoder with no arguments uses strength 10, which is a reasonable default. Raising it increases hashing time exponentially (deliberately, since that cost is what makes offline cracking expensive) so measure before changing it. A login that takes 800 ms is a denial-of-service surface of its own.

UserDetailsService over the part 1 entities

Spring Security needs a bridge from your User entity to its own UserDetails:

public class UserPrincipal implements UserDetails {

    private final Long id;
    private final String email;
    private final String password;
    private final Collection<? extends GrantedAuthority> authorities;

    public static UserPrincipal create(User user) {
        List<GrantedAuthority> authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority(role.getName().name()))
                .collect(Collectors.toList());

        return new UserPrincipal(user.getId(), user.getEmail(), user.getPassword(), authorities);
    }

    @Override public String getUsername()  { return email; }
    @Override public String getPassword()  { return password; }
    @Override public boolean isEnabled()   { return true; }
    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
}

role.getName().name() produces ROLE_USER, the enum constant’s own name, prefix included. Part 1 stored the prefix deliberately, because hasRole("USER") looks for an authority literally named ROLE_USER. Strip it here and every authorisation check fails while authentication succeeds, which is a confusing pair of symptoms.

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository users;

    public CustomUserDetailsService(UserRepository users) {
        this.users = users;
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) {
        User user = users.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("No user with email " + email));
        return UserPrincipal.create(user);
    }

    @Transactional(readOnly = true)
    public UserDetails loadUserById(Long id) {
        User user = users.findById(id)
                .orElseThrow(() -> new UsernameNotFoundException("No user with id " + id));
        return UserPrincipal.create(user);
    }
}

@Transactional on both. roles is a lazy @ManyToMany from part 1, and UserPrincipal.create touches it: without an open session that throws LazyInitializationException from inside the authentication filter, which surfaces as a 500 on every authenticated request rather than as anything mentioning Hibernate.

loadUserById exists for the token filter, which carries a user id rather than an email.

Signup and signin

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final UserRepository users;
    private final RoleRepository roles;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;

    // constructor omitted

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@Valid @RequestBody SignUpRequest request) {
        if (users.existsByEmail(request.email())) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(new ApiResponse(false, "Email address already in use"));
        }
        if (users.existsByUsername(request.username())) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(new ApiResponse(false, "Username already taken"));
        }

        User user = new User();
        user.setName(request.name());
        user.setUsername(request.username());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));

        Role userRole = roles.findByName(RoleName.ROLE_USER)
                .orElseThrow(() -> new AppException("ROLE_USER not found — was the seeder run?"));
        user.setRoles(Set.of(userRole));

        User saved = users.save(user);

        URI location = ServletUriComponentsBuilder
                .fromCurrentContextPath().path("/api/users/{username}")
                .buildAndExpand(saved.getUsername()).toUri();

        return ResponseEntity.created(location)
                .body(new ApiResponse(true, "User registered successfully"));
    }

    @PostMapping("/signin")
    public ResponseEntity<JwtAuthenticationResponse> signin(@Valid @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.email(), request.password()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
        String jwt = tokenProvider.createToken(authentication);

        return ResponseEntity.ok(new JwtAuthenticationResponse(jwt, "Bearer"));
    }
}

public record SignUpRequest(
        @NotBlank @Size(max = 40) String name,
        @NotBlank @Size(min = 3, max = 15) String username,
        @NotBlank @Email @Size(max = 40) String email,
        @NotBlank @Size(min = 8, max = 100) String password) { }

public record LoginRequest(@NotBlank @Email String email, @NotBlank String password) { }

Three decisions worth naming.

The existence checks are a race, and the unique constraints are the real defence. Two concurrent signups with the same email both pass existsByEmail before either commits; the database rejects the second with a constraint violation. Handle that too, or the user sees a 500 instead of a 409:

@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ApiResponse> duplicate(DataIntegrityViolationException e) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ApiResponse(false, "Email or username already registered"));
}

authenticationManager.authenticate throws on bad credentials (BadCredentialsException, a subclass of AuthenticationException) and Spring’s entry point turns it into a 401. Do not catch it to return a friendlier message that distinguishes “no such user” from “wrong password”: that difference tells an attacker which addresses are registered.

The minimum password length is a policy decision, not a default. Eight characters with no other rule is current guidance; composition rules (“one uppercase, one digit”) push people toward Password1! and measurably reduce entropy. Check the input against a list of known-breached passwords if you can.

Note that signup does not return a token. Registering and being logged in are separate decisions — if you later add email verification, an account that is already authenticated before verifying is awkward to unwind.

What the client does with it

// after signin
const { accessToken } = await api.post('/auth/signin', { email, password });
localStorage.setItem('accessToken', accessToken);
// attach it to every subsequent request
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// and handle expiry in one place
axios.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('accessToken');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

localStorage is the pragmatic choice and it is not the secure one. Any script running on your page can read it, so a single cross-site scripting flaw, in your code or in a dependency, hands over every user’s token. An HttpOnly cookie cannot be read by JavaScript, which removes that risk and reintroduces CSRF, so the protection you disabled in the configuration above has to come back on.

There is no option here without a trade. What matters is choosing knowingly:

  • localStorage: simple, works across subdomains and native clients, vulnerable to XSS.
  • HttpOnly; Secure; SameSite=Strict cookie: immune to XSS reads, needs CSRF tokens, and needs care when the API is on a different origin from the front end.

Either way keep the token short-lived. A seven-day token in localStorage is a seven-day window; a fifteen-minute access token with a refresh flow is a fifteen-minute one.

And note what logout means with a stateless token: removing it from the client stops that client using it, and a copy taken beforehand remains valid until it expires. Real revocation needs server state (a deny-list of token ids, or short access tokens plus refresh tokens you can revoke) which is the trade you accept in exchange for not looking up a session on every request.

Checking it

$ curl -s -X POST localhost:8080/auth/signup \
    -H 'Content-Type: application/json' \
    -d '{"name":"Ann","username":"ann","email":"[email protected]","password":"correct-horse"}'
{"success":true,"message":"User registered successfully"}

$ curl -s -X POST localhost:8080/auth/signin \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"correct-horse"}'
{"accessToken":"eyJhbGciOiJIUzUxMiJ9...","tokenType":"Bearer"}

$ curl -s localhost:8080/api/polls/me -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9..."
mysql> SELECT email, LEFT(password, 7), LENGTH(password) FROM users;
+-------------------+-------------------+------------------+
| [email protected]   | $2a$10$           |               60 |
+-------------------+-------------------+------------------+

$2a$10$ and 60 characters is what a BCrypt hash looks like. Anything shorter means the column truncated it, part 1 sized password at 100 characters for exactly this reason, and every login then fails with no useful error.

Frequently asked questions

Why does authentication succeed but authorisation fail?

The authority is probably missing its ROLE_ prefix. hasRole("USER") matches an authority literally named ROLE_USER.

Why do I get LazyInitializationException during login?

UserPrincipal.create touches the lazy roles collection. Annotate loadUserByUsername with @Transactional.

How do I get an AuthenticationManager in Spring Security 6?

Expose it as a bean from AuthenticationConfiguration.getAuthenticationManager(). The old authenticationManagerBean() override was removed.

Should signin say whether the email exists?

No. Return the same 401 for an unknown address and a wrong password. Distinguishing them reveals which addresses are registered.

Why is my login failing even with the right password?

Check the stored hash. A BCrypt hash is 60 characters; a shorter column truncates it and every comparison fails.

Is checking existsByEmail before saving enough?

No. It is a race. Two concurrent signups both pass the check. The unique constraint is the real defence, handle DataIntegrityViolationException and return 409.

What BCrypt strength should I use?

The default of 10 is reasonable. Cost grows exponentially, and a login taking hundreds of milliseconds becomes its own denial-of-service surface.

localStorage or cookies for the token?

localStorage is simple and readable by any script on the page, so one XSS flaw exposes it. An HttpOnly cookie prevents that and requires CSRF protection. Choose knowingly; keep the token short-lived either way.

Is disabling CSRF safe here?

Only while the token travels in the Authorization header. Move it into a cookie and CSRF protection must come back on.

How do I log a user out?

With a stateless token, by discarding it client-side, a copy stays valid until it expires. Real revocation needs server state: a deny-list, or short access tokens with revocable refresh tokens.

Where should I go next?

Part 1 covers the entities and repositories, and OAuth2 social login covers the provider-based alternative on the same filter chain.