05-spring-security

Authentication: Form Login, HTTP Basic and JWT

Loading users with UserDetailsService, when sessions beat tokens, and a JWT filter built properly — including the validation steps that are easy to omit.

October 9, 2026
spring-securityauthenticationjwtform-loginhttp-basicUserDetailsServicestateless

Three Mechanisms, Different Jobs

Authentication establishes who is making this request. Spring Security ships several mechanisms; three cover most applications, and they are not interchangeable.

MechanismCredential per requestStateSuits
Form loginSession cookieServer-side sessionServer-rendered web apps
HTTP BasicUsername/password every timeNoneInternal tools, scripts
JWT bearerSigned tokenNoneSPAs, mobile, service-to-service

The choice drives everything else — whether CSRF protection is needed, whether you can scale without sticky sessions, and how logout works.

Loading Users: UserDetailsService

Whatever the mechanism, Spring needs to resolve a username into a user with credentials and authorities. That contract is UserDetailsService:

java
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
 
    private final AccountRepository accounts;
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accounts.findByEmailIgnoreCase(username)
                .orElseThrow(() -> new UsernameNotFoundException("No user: " + username));
 
        return User.withUsername(account.getEmail())
                .password(account.getPasswordHash())       // the hash, never plaintext
                .authorities(account.getRoles().stream()
                        .map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))
                        .toList())
                .disabled(!account.isEnabled())
                .accountLocked(account.isLocked())
                .build();
    }
}

Declaring this bean is enough — auto-configuration backs off its in-memory default, the @ConditionalOnMissingBean behaviour from phase 1.

Note the ROLE_ prefix added here, matching hasRole("ADMIN") from the previous guide. Decide where that prefix lives — in the database or added on load — and do it in exactly one place.

🚨

Never let loadUserByUsername reveal whether an account exists. Throwing UsernameNotFoundException for unknown users and a bad-credentials error for wrong passwords lets an attacker enumerate your user base. Spring Security's DaoAuthenticationProvider already normalises both into BadCredentialsException and performs a dummy password check when the user is missing, so timing doesn't leak either — but only if you don't defeat it by returning a distinct error from your own code or an earlier endpoint.

Form Login

java
@Bean
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/css/**", "/js/**").permitAll()
            .anyRequest().authenticated())
        .formLogin(form -> form
            .loginPage("/login")
            .loginProcessingUrl("/login")
            .defaultSuccessUrl("/dashboard", true)
            .failureUrl("/login?error")
            .permitAll())
        .logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login?logout")
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID"))
        .sessionManagement(s -> s
            .sessionFixation(SessionFixationConfigurer::migrateSession)
            .maximumSessions(1))
        .build();
}

Two settings there are security controls rather than conveniences.

sessionFixation().migrateSession() issues a new session ID on login. Without it, an attacker who plants a known session ID in a victim's browser holds a valid authenticated session once the victim logs in — session fixation. This is Spring's default; don't turn it off.

CSRF protection must stay enabled. Form login authenticates with a cookie the browser attaches automatically, which is precisely the condition CSRF exploits. The last guide in this phase covers it.

HTTP Basic

java
http.httpBasic(Customizer.withDefaults());

Credentials are sent on every request as a base64 header — encoded, not encrypted. Over plain HTTP that's equivalent to sending the password in the clear, so Basic requires TLS without exception.

Reasonable for an internal service, a curl call or a scripted client. Not for a browser application: there's no logout (the browser caches credentials), no password reset flow, and the native prompt can't be styled.

JWT: What a Token Buys

A JWT is a signed, self-describing credential. The server verifies the signature and reads the claims — no session lookup and no shared session store, which is what makes it attractive for horizontally scaled APIs.

java
@Bean
SecurityFilterChain apiChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
    return http
        .securityMatcher("/api/**")
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
            .anyRequest().authenticated())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(CsrfConfigurer::disable)
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling(ex -> ex
            .authenticationEntryPoint(problemDetailEntryPoint()))
        .build();
}

The filter

java
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
 
    private final JwtService jwtService;
    private final UserDetailsService users;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);           // not our concern; pass on
            return;
        }
        try {
            Claims claims = jwtService.parseAndValidate(header.substring(7));
            UserDetails user = users.loadUserByUsername(claims.getSubject());
            var auth = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());
            auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
 
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(auth);
            SecurityContextHolder.setContext(context);
        } catch (JwtException ex) {
            log.debug("Rejected JWT: {}", ex.getMessage());   // don't populate the context
        }
        chain.doFilter(request, response);
    }
}

The structure follows the rules from the previous guide: OncePerRequestFilter, a fresh context, always call chain.doFilter, and never reject the request here — leave that to AuthorizationFilter.

⚠️

Calling loadUserByUsername on every request means a database query per request, which undoes part of the point of a self-contained token. The alternatives are to trust the authorities in the token's claims (fast, but a revoked role stays valid until the token expires) or to cache user lookups with a short TTL. Both are reasonable; choose knowingly rather than by default.

Validating properly

The part that is easy to get wrong, and where the failure mode is accepting tokens you should reject:

java
@Service
public class JwtService {
 
    private final SecretKey key;
    private final String issuer;
    private final String audience;
 
    public Claims parseAndValidate(String token) {
        return Jwts.parser()
                .verifyWith(key)                          // signature
                .requireIssuer(issuer)                    // who minted it
                .requireAudience(audience)                // who it's for
                .clockSkewSeconds(30)                     // tolerate small clock drift
                .build()
                .parseSignedClaims(token)                 // rejects unsigned tokens
                .getPayload();                            // expiry checked here
    }
}

Five checks, all mandatory:

  1. Signature, with an explicitly expected algorithm.
  2. Expiry (exp), which the parser enforces.
  3. Issuer (iss) — otherwise a token from any system sharing your key works.
  4. Audience (aud) — otherwise a token minted for a different service at the same issuer authenticates against yours.
  5. Clock skew tolerance, or you'll reject valid tokens across machines.
🚨

Two classic JWT vulnerabilities, both complete authentication bypasses.

The alg: none attack — a library that reads the algorithm from the token's own header will accept an unsigned token claiming no algorithm. Never let the token choose; parseSignedClaims with verifyWith pins it.

Algorithm confusion — a token signed with HMAC using your public RSA key as the secret, accepted by a verifier that infers the algorithm. Again: specify what you expect.

This is the case for not hand-rolling. Spring's oauth2ResourceServer support does all five checks plus JWKS rotation, and is harder to get subtly wrong.

Prefer the resource server for standard tokens

If your tokens come from an identity provider — Keycloak, Auth0, Entra ID, Cognito — don't write the filter at all:

yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/shop
          audiences: shop-api
java
http.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));

That gives signature verification, issuer and audience validation, expiry, JWKS fetching with key rotation and caching, and skew tolerance. The next-but-one guide covers it in full.

Write your own filter when you issue your own tokens and need the control. Know that you have taken on the five checks above.

Revocation: The Real Cost of Statelessness

A JWT is valid until it expires. There is no way to revoke one without reintroducing state, which is the trade you accepted for not having sessions. So a user who logs out, has their account disabled, or has a token stolen remains authenticated until expiry.

The standard mitigation is two tokens:

java
public record TokenPair(String accessToken, String refreshToken) { }
  • Access token — short-lived (5–15 minutes), sent on every request, not checked against storage.
  • Refresh token — long-lived (days), stored server-side, used only to mint a new access token.

Revocation then means deleting the refresh token: the user loses access within one access-token lifetime. That window is the design parameter — shorter means more refresh traffic, longer means slower revocation.

⚠️

Where the frontend stores tokens matters. localStorage is readable by any JavaScript on the page, so one XSS flaw exfiltrates the token. An HttpOnly; Secure; SameSite=Strict cookie is not script-readable — but a cookie is browser-attached, so CSRF protection becomes necessary again. There is no option with no trade-off: tokens in localStorage trade XSS exposure for CSRF immunity, cookies trade the reverse. For browser apps, the common recommendation is the cookie plus CSRF protection, because XSS is the more commonly exploited of the two.

Check yourself

An API validates JWT signatures and expiry but not the audience claim. Tokens are issued by a shared corporate identity provider used by twelve internal services. What is the exposure?

Which Should You Use

Form login with sessions for a server-rendered application. Simpler, revocation is immediate, and HttpOnly cookies with CSRF protection is a well-understood combination. Horizontal scaling needs a shared session store — Spring Session with Redis is a few lines and removes the sticky-session requirement.

JWT for an API with non-browser clients, or where you genuinely need stateless verification across many services. Accept the revocation window and plan for refresh.

HTTP Basic for internal and machine clients over TLS.

Statelessness is often chosen reflexively. If your API serves one SPA from one deployment, a session in Redis is less machinery and gives instant revocation — and "stateless" with a server-stored refresh token is already not stateless.

The Mental Model, Restated

  1. UserDetailsService resolves a username to credentials and authorities — never leak whether an account exists.
  2. Form login means a cookie, so CSRF protection stays on; keep session fixation protection.
  3. HTTP Basic sends credentials every request — TLS only, machine clients only.
  4. A JWT filter must validate signature, expiry, issuer, audience and skew — five checks, and omitting any accepts tokens it shouldn't.
  5. Pin the algorithm. alg: none and algorithm confusion are full bypasses.
  6. Use oauth2ResourceServer for provider-issued tokens rather than hand-rolling.
  7. JWTs cannot be revoked — short access tokens plus a stored refresh token is the standard answer.
  8. localStorage risks XSS; cookies require CSRF protection. Pick deliberately.

What's Next

Every mechanism here compares a presented credential against a stored one, and for passwords that stored value must never be the password itself. The next guide covers password encoding: why BCrypt or Argon2 rather than SHA, how the work factor trades latency for attack cost, and how DelegatingPasswordEncoder migrates legacy hashes without forcing a reset.