Skip to content

CWE-287: Improper Authentication - Java

Overview

In Spring applications, authentication normally flows through Spring Security's AuthenticationManager/AuthenticationProvider chain, which validates a credential and produces an authenticated Authentication object stored in SecurityContextHolder. Improper authentication commonly appears as a custom AuthenticationProvider whose authenticate() method returns success without actually verifying the credential, or whose supports() check is too broad and lets ProviderManager route unintended authentication types to it. It also appears in JWT handling: libraries such as io.jsonwebtoken (jjwt) expose both signature-checked and unsigned parsing methods, and calling the unsigned variant (or configuring NimbusJwtDecoder without a pinned algorithm) lets the token's own alg header - including none - decide how it gets verified.

The fix is to make authenticate() throw on any unverified credential, parse JWTs only through the signature-checked API bound to a correctly typed key, and enforce authentication in the SecurityFilterChain so a misconfigured provider cannot leave a route reachable without it.

Common Vulnerable Patterns

AuthenticationProvider That Returns Success Without Verifying the Credential

// VULNERABLE - looks up the user and returns success without checking the password
@Component
public class AccountAuthenticationProvider implements AuthenticationProvider {
    private final UserDetailsService userDetailsService;

    public AccountAuthenticationProvider(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    public Authentication authenticate(Authentication auth) throws AuthenticationException {
        String username = auth.getName();
        UserDetails user = userDetailsService.loadUserByUsername(username); // throws if not found

        // No password comparison at all - reaching this line means "authenticated"
        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

// Attack example:
// POST /login with username=admin and any password value, including an empty one
// Result: authenticated as admin - the provider never checked the credential

Why this is vulnerable: Because loadUserByUsername only confirms the account exists, any known username authenticates successfully regardless of the password supplied. This class of bug is easy to introduce when a provider is written to "get login working" before the credential check is added, and easy to miss in review because the method signature looks correct.

JWT Parsed With an Unsigned or Unverified Method

// VULNERABLE - parse() performs no signature verification at all
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Claims;

Claims claims = (Claims) Jwts.parser()
        .parse(token)   // pre-0.12 unsigned parse; accepts alg:none and forged signatures
        .getBody();

String subject = claims.getSubject();

// Attack example:
// A token with header {"alg":"none"} and no signature segment
// Result: parse() succeeds and returns attacker-controlled claims as if verified

Why this is vulnerable: The unsigned parse()/parseClaimsJwt() methods exist for reading claims from a token whose signature was already checked elsewhere - using them as the only check means the signature is never validated, so any claim set an attacker constructs is accepted as genuine.

Check the version before writing this up as an alg: none acceptance. From jjwt 0.12 these paths reject an unsecured token by default - DefaultJwtParser answers "Unsecured JWSs (those with an alg header value of 'none') are disallowed by default as mandated by RFC 7518 Section 3.6. If you wish to allow them to be parsed, call the JwtParserBuilder.unsecured() method" - so on a current release the thing to look for is that opt-in rather than the method choice. Both methods are deprecated as of 0.12, not removed, so the call still compiles.

Session Fixation - Session ID Not Regenerated on Login

// VULNERABLE - stores the authenticated user in whatever session already exists
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password, HttpSession session) {
    if (accountService.checkCredentials(username, password)) {
        session.setAttribute("user", username); // reuses the pre-login session ID
        return "redirect:/dashboard";
    }
    return "redirect:/login?error";
}

// Attack example:
// Attacker sends the victim a link containing a known JSESSIONID, victim logs in,
// the session ID stays the same and is now authenticated - attacker reuses it

Why this is vulnerable: If the session identifier issued before login is the same one used after login, an attacker who can set that identifier on the victim's browser (via a crafted link, a subdomain cookie, or a network position) inherits the authenticated session once the victim logs in.

Secure Patterns

AuthenticationProvider That Verifies the Credential Before Returning Success

// SECURE - throws unless the password actually matches the stored hash, and spends
// the same time on an unknown username
@Component
public class AccountAuthenticationProvider implements AuthenticationProvider {
    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    // A real BCrypt hash at the same strength, used only to spend the same time on
    // an unknown username. It has to be a genuine hash: matches() against "" or a
    // malformed string fails its format check and returns without hashing.
    private static final String DUMMY_HASH =
            "$2a$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG";

    public AccountAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public Authentication authenticate(Authentication auth) throws AuthenticationException {
        String username = auth.getName();
        Object credentials = auth.getCredentials();
        // Hash something either way; a null credential is rejected below, not here,
        // so the decision does not change how long the request takes.
        String rawPassword = credentials == null ? "" : credentials.toString();

        UserDetails user = null;
        try {
            user = userDetailsService.loadUserByUsername(username);
        } catch (UsernameNotFoundException ignored) {
            // Fall through with user == null; a UserDetailsService may also
            // return null instead of throwing, so both are handled below.
        }

        // Hash on both branches, so the response time does not say which usernames exist
        String storedHash = (user == null || user.getPassword() == null)
                ? DUMMY_HASH : user.getPassword();
        boolean matched = passwordEncoder.matches(rawPassword, storedHash);

        if (user == null || credentials == null || !matched) {
            throw new BadCredentialsException("Invalid username or password");
        }
        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

Why this works: PasswordEncoder.matches() compares the submitted password against the stored hash using the encoder's own verification logic (BCrypt by default in Spring Security), which is constant-time and salt-aware. Throwing BadCredentialsException on any mismatch or null credential means authenticate() has exactly one path to success: a verified match. Scoping supports() to UsernamePasswordAuthenticationToken also stops ProviderManager from routing an unrelated authentication type (for example a pre-authenticated or SSO token) into a provider that was never designed to validate it.

Running matches() on both branches is what stops the response time from answering "does this username exist". loadUserByUsername throws UsernameNotFoundException for an unknown username, and letting that propagate returns before any hashing happens: measured on JDK 26 with BCryptPasswordEncoder(12), a wrong password took 254 ms and an unknown username 0.002 ms - a 138,000x gap. The dummy has to be a genuine 60-character BCrypt hash at the same strength; BCryptPasswordEncoder rejects anything that fails its format check without hashing, so matches(rawPassword, "") returns in 0.22 ms, leaves a 1,100x gap, and logs WARNING: Empty encoded password on every failed login. Catching UsernameNotFoundException also stops it reaching an AuthenticationFailureHandler that would distinguish it from BadCredentialsException in the response or the logs - the timing channel and the message channel answer the same question. Every login now pays the full hashing cost, so rate-limit the endpoint.

Spring Security's own DaoAuthenticationProvider already does all of this, so the cheapest fix for this finding is usually to delete the custom provider and configure a UserDetailsService plus a PasswordEncoder bean instead. It encodes a fixed userNotFoundPassword and runs matches() against it when loadUserByUsername throws. The encode is lazy rather than done at start-up: prepareTimingAttackProtection() is called from retrieveUser() under if (this.userNotFoundEncodedPassword == null), so it happens on the first authentication attempt, and setPasswordEncoder() clears the field and defers it again. doAfterPropertiesSet() only asserts that a UserDetailsService is set. Two conditions bound the decoy as well: it runs on UsernameNotFoundException specifically, not on other retrieveUser failures, and only when authentication.getCredentials() is non-null. Write the provider only when it has to do something DaoAuthenticationProvider cannot.

JWT Parsed and Verified With a Typed Key

// SECURE - jjwt 0.12+, verify with a typed key; alg:none and mismatched algorithms are rejected
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Claims;
import javax.crypto.SecretKey;

SecretKey key = Jwts.SIG.HS256.key().build(); // load a persisted key from config/secret store in production

Jws<Claims> jws = Jwts.parser()
        .verifyWith(key)            // binds verification to an HMAC key family
        .build()
        .parseSignedClaims(token);  // throws on unsigned, alg:none, or algorithm mismatch

String subject = jws.getPayload().getSubject();

Why this works: verifyWith(key) binds parsing to one specific key and algorithm family, so parseSignedClaims() throws for any token whose alg header does not match. Measured on jjwt 0.13.0: a token signed with a different HMAC key throws io.jsonwebtoken.security.SignatureException (not the deprecated io.jsonwebtoken.SignatureException an older example or an IDE auto-import may reach for), an alg: none token throws UnsupportedJwtException, and an expired one throws ExpiredJwtException - all subclasses of JwtException, so catch that rather than enumerating them. Because the key type (SecretKey for HMAC) is fixed in code rather than inferred from the token, an attacker cannot switch algorithm families to make an asymmetric public key double as an HMAC secret. Claims are only readable after verification succeeds - there is no code path that returns claims from a token that failed the signature check.

Regenerate the Session on Successful Login

// SECURE - Spring Security regenerates the session ID on authentication success
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .sessionManagement(session -> session
            .sessionFixation(SessionFixationConfigurer::changeSessionId) // default on Servlet 3.1+, shown explicitly
            .maximumSessions(1))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/public/**").permitAll()
            .anyRequest().authenticated());
    return http.build();
}

Why this works: sessionFixation(SessionFixationConfigurer::changeSessionId) delegates to the container's HttpServletRequest.changeSessionId(), which issues a new session identifier for the same session at the moment authentication succeeds, so any pre-login session ID an attacker set is discarded rather than promoted to an authenticated one. Existing attributes survive because the session object itself is not replaced. This has been Spring Security's default on Servlet 3.1+ containers since Spring Security 4; migrateSession (create a new session and copy attributes across) is the older default, still selected automatically on Servlet 3.0 and earlier, and newSession starts a clean session without copying application attributes. maximumSessions(1) is defense in depth rather than part of the fix: it invalidates any other concurrent session for the same user, reducing the value of a stolen session token.

Framework-Specific Guidance

Spring Security Resource-Server JWT Validation

// SECURE - resource server decodes and validates JWTs with a pinned algorithm
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withJwkSetUri(issuerJwkSetUri)
        .jwsAlgorithm(SignatureAlgorithm.RS256) // pinned in code, not inferred from the token
        .build();
}

@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

NimbusJwtDecoder with an explicit jwsAlgorithm() (or jwtProcessorCustomizer pinning the accepted algorithm set) keeps the algorithm decision in application configuration rather than the token itself, and oauth2ResourceServer().jwt() wires the decoder into every request on the protected filter chain, so there is no code path that reaches a controller without a validated token.

Spring Security Filter Chain Coverage

// SECURE - explicit deny-by-default; anyRequest().authenticated() is the last matcher
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/login", "/register", "/actuator/health").permitAll()
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());

Ordering matters: anyRequest().authenticated() as the final rule means any route not explicitly listed requires authentication, so a controller added later without updating this configuration is protected rather than open.

Testing

  • Use @WithMockUser for authorized-path tests and a plain MockMvc request with no Authorization header for the unauthorized path - expect 401/403 as configured.
  • Submit an incorrect password and confirm BadCredentialsException is thrown, not a successful authentication.
  • Time three calls to authenticate() - known username with the right password, known username with a wrong password, unknown username - and assert all three are within noise of each other. A sub-millisecond answer for the unknown username is the enumeration oracle, and a re-scan cannot see it. Assert on the exception type as well: BadCredentialsException for both failures, never UsernameNotFoundException or a NullPointerException from user.getPassword().
  • Forge a token with alg: none or a mismatched algorithm and confirm parseSignedClaims()/NimbusJwtDecoder rejects it with a 401.
  • Log in, capture the session cookie, then verify a session ID captured before login is no longer valid after authentication (session fixation test).
  • Re-scan with the security tool that originally reported the finding to confirm it no longer fires.

Common Pitfalls

  • Adding a password check to the provider but leaving supports() too broad (for example return true or matching a supertype), which lets ProviderManager still delegate an unrelated Authentication type into this provider - narrow supports() to the exact subtype the provider actually validates.
  • Verifying the JWT signature correctly but reading claims from a second, unsigned decode used earlier in the same request for logging or debugging - if that unsigned decode's output is ever reused for an authorization decision, the verified path is bypassed entirely.
  • Configuring NimbusJwtDecoder from a JWK Set URI without pinning jwsAlgorithm(), relying on the key's own type to constrain the algorithm - an attacker-controlled alg header can still attempt an algorithm-confusion match against the fetched key material.
  • Leaving session-fixation protection enabled but exempting a custom login endpoint that manually reads HttpSession and skips the standard authentication filter chain - any bespoke login path needs the same session regeneration the framework applies automatically to the standard path.

Dependencies and Installation

  • org.springframework.boot:spring-boot-starter-security - brings in Spring Security's AuthenticationManager, filter chain, and PasswordEncoder support.
  • io.jsonwebtoken:jjwt-api, jjwt-impl, jjwt-jackson (jjwt 0.12.x or later) for JWT issuing/parsing; use the verifyWith()/parseSignedClaims() API introduced in 0.12, not the deprecated 0.11.x setSigningKey()/parseClaimsJws() forms in new code.
  • org.springframework.boot:spring-boot-starter-oauth2-resource-server for NimbusJwtDecoder-based resource-server validation against an external identity provider's JWK Set.

Migration Considerations

Switching an AuthenticationProvider from "any known username succeeds" to a real credential check will reject sessions or automated integrations that were relying on the previous behavior - audit for any service account or test harness that depends on it before deploying. Tightening JWT validation (pinning jwsAlgorithm(), moving off unsigned parse()) invalidates tokens that were previously accepted without full verification; coordinate a rollout window with token issuers and monitor for a spike in 401 responses. Enabling session-fixation protection where it was previously set to none will invalidate any session state that assumed a stable session ID across the login boundary - test any code that caches the pre-login session ID for correlation or analytics.

Additional Resources