Skip to main content

Command Palette

Search for a command to run...

Authentication Vulnerabilities in Java: Password Security & Rate Limiting (Part 1)

Updated
17 min read
Authentication Vulnerabilities in Java: Password Security & Rate Limiting (Part 1)
M

20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.

Authentication is the most critical security boundary within a web application. A user claims to be Alice: the application has to validate that claim before granting access. Despite the long history of established best practices and the availability of comprehensive frameworks, the rate of authentication security weaknesses in production applications remains disturbing.

Authentication failures come under CWE-287. They also come under A07:2025 Identification and Authentication Failures in the OWASP Top 10 2025. These vulnerabilities include weak passwords, credential stuffing, session flaws, as well as the absence of the use of multi-factors. These vulnerabilities have come to dominate due to the application of knowledge rather than the knowledge itself.

In this, and couple future posts, I will try to examine authentication vulnerabilities from two perspectives:

  • What developers need to know: concrete implementation failures in the Java ecosystem.

  • What security architects need to know: organizational strategy, compliance requirements and security programs integration.

1. Password Requirements: The Entropy Problem

The first authentication barrier is password policy. The common password requirements of 8 characters minimum, one uppercase letter, one lowercase letter, one number, and one special character were never effective, they simply looked good on a compliance checklist.

The Fundamental Misconception

Consider two passwords:

  • P@ssw0rd1 — Satisfies traditional complexity requirements

  • correct horse battery staple — Violates most complexity requirements

Password strength is measured in entropy—the number of guesses required for exhaustive search:

Entropy = log₂(character_set_size^password_length)

For P@ssw0rd1:

  • Character set: ~72 characters (uppercase, lowercase, digits, symbols)

  • Length: 9 characters

  • Entropy: log₂(72^9) ≈ 56 bits

For correct horse battery staple:

  • Character set: 26 characters (lowercase only)

  • Length: 28 characters

  • Entropy: log₂(26^28) ≈ 131 bits

The passphrase provides more than double the entropy despite using a smaller character set. Length wins. Every time.

Of course, the catch here is that this was all based on the idea of random selection. In the real world, users are not going to create random passwords. They are going to follow predictable patterns of capitalizing the first character, adding numbers to the end of the password, replacing "a" with "@". the password "P@ssw0rd1" is in just about every password breach list and will be the first password an attacker attempts to crack. Your complexity requirements will simply teach users how to create passwords that are hard for people to memorize but are trivial for computers to guess.

NIST SP 800-63B-4: Evidence-Based Password Policy

The National Institute of Standards and Technology released NIST SP 800-63B-4 in July 2025, revising password best practices based on empirical research. The guidelines still defy traditional password best practices, with a few strengthened from earlier iterations.

Minimum length, no maximum restrictions. For password-only authentication, the minimum length should be 15. For password authentication as part of multi-factor authentication, the minimum length should be 8. There should be no maximum length restrictions, although the ability to handle 64 or more characters is a good requirement. If the maximum length is 16 or 20, this could be a warning sign that password storage is potentially problematic.

Breach database checking. Check passwords against breach databases such as haveibeenpwned.com, where there are over 600 million passwords listed as having been involved in security breaches. If the entered password is found to be in this breach database, then the password should be rejected. This measure will prevent the majority of credential stuffing attacks.

Eliminate periodic rotation. Compulsory forced 90-day password changes encourage users to make weak modifications. Users will change "Password1" to "Password2," "Password3," etc. Passwords should be changed only in the event of compromise

Ban common passwords. Disallow passwords from common password lists ("password", "123456", "qwerty"). Really obvious one here, but surprisingly often still allowed on systems.

Prohibition of complexity requirements. Organizations should not enforce arbitrary composition constraints like requiring a combination of character types such as uppercase and lowercase letters, numbers, and special characters. This language has also changed from "should not" to "shall not" in the 2025 revision of this standard. Such constraints are predictable and difficult for users to remember but easy for attackers to guess.

Allow password managers. Eliminate the restriction that blocks paste operations. Such a restriction is a disincentive to the use of password managers and drives people to use weaker passwords that they actually can input.

Prevention of password reuse. Implement a password hash history of 5-10 passwords and reject matches.

CASMM: Authentication Maturity Levels

Daniel Miessler has proposed a useful model in the form of a Consumer Authentication Security Maturity Model that will allow us to assess where our system stands:

CM1 - Single Factor. Password-only based authentications. These are not considered secure for systems that carry high levels of sensitive information. Systems that only use the CM1 level are vulnerable to credential stuffing, phishing, and breach databases.

CM2 - Enhanced Single Factor. Passwords with breach checking, rate limiting, and account lockout. It is the minimal security required for most systems.

CM3 – Multi-Factor Authentication. Password combined with a second factor such as SMS one-time passwords, time-based one-time passwords, or a push notification. SMS one-time passwords are vulnerable to SIM-swap attacks. Push notifications are vulnerable to "MFA fatigue" attacks, whereby the attacker sends a high volume of requests to the user, forcing the user to approve one by accident. TOTP is the best choice available at this level.

CM4 - Risk-Based, Passwordless. Adaptive authentication through the use of FIDO2/WebAuthn or Hardware Security Tokens. This model includes the use of contextual risk analysis, which includes device fingerprinting, behavior, and impossible travel. It is phishing resistant due to the use of a cryptographic challenge-response model instead of a shared secret.

For organizations with sensitive information, the goal should be to meet the CM3 level or better for privileged accounts and admin access.

Implementation: Server-Side Password Validation

Client-side password strength feedback improves UX but is not a security control. All validation must occur server-side—anything in the browser can be bypassed with dev tools:

import org.apache.commons.codec.digest.DigestUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Service
public class PasswordValidationService {

    private static final Logger logger =         LoggerFactory.getLogger(PasswordValidationService.class);
    private static final int MIN_LENGTH = 15;
    private static final int MAX_LENGTH = 128;

    private final Set<String> commonPasswords;
    private final PasswordEncoder passwordEncoder;
    private final RestClient restClient;

    public PasswordValidationService(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
        this.commonPasswords = loadCommonPasswords();
        this.restClient = RestClient.builder()
                .baseUrl("https://api.pwnedpasswords.com")
                .build();
    }

    private Set<String> loadCommonPasswords() {
        Set<String> passwords = new HashSet<>();
        try (InputStream is = getClass()
                           .getResourceAsStream("/common-passwords.txt")) {
            if (is != null) {
                try (BufferedReader reader = new BufferedReader(
                        new InputStreamReader(is, StandardCharsets.UTF_8))) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        passwords.add(line.toLowerCase().trim());
                    }
                }
            }
        } catch (IOException e) {
            logger.warn("Failed to load common passwords list", e);
        }
        return passwords;
    }

    public ValidationResult validatePassword(String password, User user) {
        List<String> errors = new ArrayList<>();

        if (password.length() < MIN_LENGTH) {
            errors.add("Password must be at least " + MIN_LENGTH + " characters");
        }
        if (password.length() > MAX_LENGTH) {
            errors.add("Password must not exceed " + MAX_LENGTH + " characters");
        }
        if (commonPasswords.contains(password.toLowerCase())) {
            errors.add("Password is too common");
        }
        if (isPasswordPwned(password)) {
            errors.add("Password has appeared in data breaches");
        }
        if (user != null && matchesRecentPassword(password, user)) {
            errors.add("Cannot reuse recent passwords");
        }
        if (user != null && containsUserInfo(password, user)) {
            errors.add("Password cannot contain username or email");
        }

        return new ValidationResult(errors.isEmpty(), errors);
    }

    private boolean isPasswordPwned(String password) {
        String sha1 = DigestUtils.sha1Hex(password).toUpperCase();
        String prefix = sha1.substring(0, 5);
        String suffix = sha1.substring(5);

        try {
            String response = restClient.get()
                    .uri("/range/{prefix}", prefix)
                    .retrieve()
                    .body(String.class);

            if (response != null) {
                return response.lines()
                        .map(line -> line.split(":")[0])
                        .anyMatch(hash -> hash.equals(suffix));
            }
        } catch (Exception e) {
            logger.warn("HIBP API check failed, allowing password", e);
        }
        return false;
    }

    private boolean matchesRecentPassword(String password, User user) {
        return user.getPasswordHistory().stream()
                .anyMatch(hash -> passwordEncoder.matches(password, hash));
    }

    private boolean containsUserInfo(String password, User user) {
        String lowerPassword = password.toLowerCase();
        String username = user.getUsername().toLowerCase();
        String email = user.getEmail().split("@")[0].toLowerCase();
        return lowerPassword.contains(username) || lowerPassword.contains(email);
    }
}

record ValidationResult(boolean valid, List<String> errors) {
    public boolean isValid() {
        return valid;
    }
}

The Have I Been Pwned k-anonymity API sends only the first 5 characters of the SHA-1 hash. HIBP returns all hash suffixes matching that prefix, and we can check locally. This prevents sending complete passwords or hashes over the network while still providing breach checking.

2. Rate Limiting: Preventing Brute Force

Without rate limiting, our login endpoint is a password cracker with exceptional uptime. A malicious actor could try thousands of passwords a minute, limited by network latency and server capacity. Credential stuffing attacks take this a step further by trying millions of credentials from breach databases.

Vulnerable: No Rate Limiting

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class VulnerableAuthController {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public VulnerableAuthController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @PostMapping("/api/auth/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        User user = userRepository.findByUsername(request.username())
                .orElse(null);

        if (user == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("User not found");
        }

        if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("Invalid password");
        }

        return ResponseEntity.ok("Login successful");
    }
}

There are several problems with this endpoint. There are no limitations to how often a request can be made. This means that an arbitrary number of attempts to enter a password are allowed. There are different error messages depending on whether or not the user exists. There are no lockouts. There are no failed attempts logged.

Secure: Dual-Layer Rate Limiting

Effective rate limiting requires two layers: per-account limiting (prevents targeting individual users) and per-IP limiting (prevents distributed attacks from single sources).

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.Optional;

@Service
public class AuthenticationThrottleService {

    private static final Logger logger = LoggerFactory.getLogger(AuthenticationThrottleService.class);

    private static final int MAX_ATTEMPTS_PER_ACCOUNT = 5;
    private static final int MAX_ATTEMPTS_PER_IP = 20;
    private static final Duration WINDOW_DURATION = Duration.ofMinutes(15);
    private static final Duration LOCKOUT_BASE = Duration.ofMinutes(5);
    private static final int MAX_LOCKOUT_MULTIPLIER = 6;

    private final StringRedisTemplate redisTemplate;

    public AuthenticationThrottleService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public ThrottleResult checkThrottle(String username, String ipAddress) {
        String accountKey = "auth:throttle:account:" + username.toLowerCase();
        String ipKey = "auth:throttle:ip:" + ipAddress;
        String lockoutKey = "auth:lockout:account:" + username.toLowerCase();

        // Check if account is locked out
        String lockoutUntil = redisTemplate.opsForValue().get(lockoutKey);
        if (lockoutUntil != null) {
            Instant until = Instant.parse(lockoutUntil);
            if (Instant.now().isBefore(until)) {
                Duration remaining = Duration.between(Instant.now(), until);
                logger.warn("Account {} is locked out for {} more seconds",
                        username, remaining.toSeconds());
                return ThrottleResult.lockedOut(remaining);
            }
        }

        // Check account-based rate limit
        int accountAttempts = getAttemptCount(accountKey);
        if (accountAttempts >= MAX_ATTEMPTS_PER_ACCOUNT) {
            int lockoutMultiplier = Math.min(accountAttempts / MAX_ATTEMPTS_PER_ACCOUNT,
                    MAX_LOCKOUT_MULTIPLIER);
            Duration lockoutDuration = LOCKOUT_BASE.multipliedBy(lockoutMultiplier);
            setLockout(lockoutKey, lockoutDuration);
            logger.warn("Account {} locked out for {} minutes after {} attempts",
                    username, lockoutDuration.toMinutes(), accountAttempts);
            return ThrottleResult.lockedOut(lockoutDuration);
        }

        // Check IP-based rate limit
        int ipAttempts = getAttemptCount(ipKey);
        if (ipAttempts >= MAX_ATTEMPTS_PER_IP) {
            logger.warn("IP {} exceeded rate limit with {} attempts", ipAddress, ipAttempts);
            return ThrottleResult.rateLimited();
        }

        return ThrottleResult.allowed();
    }

    public void recordAttempt(String username, String ipAddress, boolean success) {
        String accountKey = "auth:throttle:account:" + username.toLowerCase();
        String ipKey = "auth:throttle:ip:" + ipAddress;

        if (success) {
            redisTemplate.delete(accountKey);
            logger.debug("Reset throttle counter for account {}", username);
        } else {
            incrementAttempt(accountKey);
            incrementAttempt(ipKey);
        }
    }

    public void resetFailures(String username, String ipAddress) {
        String accountKey = "auth:throttle:account:" + username.toLowerCase();
        String lockoutKey = "auth:lockout:account:" + username.toLowerCase();
        redisTemplate.delete(accountKey);
        redisTemplate.delete(lockoutKey);
    }

    private int getAttemptCount(String key) {
        String value = redisTemplate.opsForValue().get(key);
        return value != null ? Integer.parseInt(value) : 0;
    }

    private void incrementAttempt(String key) {
        redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, WINDOW_DURATION);
    }

    private void setLockout(String key, Duration duration) {
        Instant until = Instant.now().plus(duration);
        redisTemplate.opsForValue().set(key, until.toString(), duration);
    }
}

record ThrottleResult(ThrottleStatus status, Duration lockoutRemaining) {
    
    public static ThrottleResult allowed() {
        return new ThrottleResult(ThrottleStatus.ALLOWED, null);
    }

    public static ThrottleResult rateLimited() {
        return new ThrottleResult(ThrottleStatus.RATE_LIMITED, null);
    }

    public static ThrottleResult lockedOut(Duration remaining) {
        return new ThrottleResult(ThrottleStatus.LOCKED_OUT, remaining);
    }

    public boolean isAllowed() {
        return status == ThrottleStatus.ALLOWED;
    }
}

enum ThrottleStatus {
    ALLOWED, RATE_LIMITED, LOCKED_OUT
}

Note the exponential backoff. After 5 failures, the account is locked for 5 minutes. After 10 failures, the account is locked for 10 minutes. After 15 failures, the account is locked for 15 minutes. This puts a limit on the number of passwords that are tested while not disrupting the account as much as a lockout does.

Why Redis?

Redis supports atomic operations, expiration of keys, as well as horizontal scaling. In-memory rate limiting is not sufficient when the application has more than one instance running. The attacker will spread the requests across the application's load balancers, where each instance will only see a fraction of the attacks. Redis will centralize the state.

For Redis, we may decide to fail open or closed based on how risk-averse you are. To fail open means to allow requests even when Redis is down. To fail closed means to refuse requests even when Redis is down. For most applications, it's better to fail open with degraded rate limiting than to make authentication completely unavailable.

3. Username Enumeration: The Silent Information Leak

Enumeration of usernames enables the attacker to identify which usernames exist within our system. While the above attack may initially seem harmless—"so they know alice@example.com has an account, so what?"—an attacker can utilize the above attack to carry out targeted credential stuffing attacks, social engineering attacks, and privacy violation attacks.

Vulnerable: Different Error Messages

@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    User user = userRepository.findByUsername(request.username())
            .orElse(null);

    if (user == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body("User not found");  // Reveals username doesn't exist
    }

    if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body("Invalid password");  // Reveals username exists
    }

    return ResponseEntity.ok("Login successful");
}

An attacker testing usernames receives "User not found" for non-existent accounts and "Invalid password" for existing ones. Automated tools can enumerate thousands of emails in minutes.

Vulnerable: Timing Differences

Even with identical error messages, timing can leak information:

@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    User user = userRepository.findByUsername(request.username())
            .orElse(null);

    if (user == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body("Invalid credentials");  // Returns immediately - fast
    }

    if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body("Invalid credentials");  // After ~250ms bcrypt - slow
    }

    return ResponseEntity.ok("Login successful");
}

When the user exists, bcrypt comparison takes approximately 250ms (with cost factor 12). When the user doesn't exist, the response returns immediately. An attacker measuring response times can distinguish valid from invalid usernames with high accuracy.

Secure: Constant-Time Authentication

The fix requires performing equivalent work regardless of whether the username exists:

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/auth")
public class SecureAuthController {

    private static final Logger logger = LoggerFactory.getLogger(SecureAuthController.class);

    // Pre-computed bcrypt hash for timing equalization
    // This must use the same cost factor as real password hashes
    private static final String DUMMY_HASH =
            "\(2a\)12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.AQPVsBXe3MZxlm";

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationThrottleService throttleService;
    private final JwtService jwtService;

    public SecureAuthController(UserRepository userRepository,
                                PasswordEncoder passwordEncoder,
                                AuthenticationThrottleService throttleService,
                                JwtService jwtService) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.throttleService = throttleService;
        this.jwtService = jwtService;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletRequest httpRequest) {

        String ipAddress = getClientIp(httpRequest);

        // Check rate limiting before any processing
        ThrottleResult throttleResult = throttleService.checkThrottle(
                request.username(), ipAddress);

        if (!throttleResult.isAllowed()) {
            logger.warn("Request throttled for username: {} from IP: {}",
                    request.username(), ipAddress);
            return switch (throttleResult.status()) {
                case LOCKED_OUT -> ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                        .body(new ErrorResponse("Account temporarily locked. Try again in " +
                                throttleResult.lockoutRemaining().toMinutes() + " minutes."));
                case RATE_LIMITED -> ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                        .body(new ErrorResponse("Too many requests. Please slow down."));
                default -> ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                        .body(new ErrorResponse("Too many requests."));
            };
        }

        User user = userRepository.findByUsername(request.username()).orElse(null);
        boolean authenticated = false;

        if (user != null) {
            authenticated = passwordEncoder.matches(request.password(), user.getPasswordHash());
        } else {
            // Perform dummy hash comparison to equalize timing
            // This takes the same ~250ms as a real comparison
            passwordEncoder.matches(request.password(), DUMMY_HASH);
        }

        throttleService.recordAttempt(request.username(), ipAddress, authenticated);

        if (authenticated) {
            throttleService.resetFailures(request.username(), ipAddress);
            String token = jwtService.generateToken(user);

            logger.info("Successful login for user: {} from IP: {}",
                    user.getUsername(), ipAddress);

            return ResponseEntity.ok(new LoginResponse(token));
        }

        logger.warn("Failed login attempt for username: {} from IP: {}",
                request.username(), ipAddress);

        // Same message regardless of failure reason
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(new ErrorResponse("Invalid credentials"));
    }

    private String getClientIp(HttpServletRequest request) {
        String xff = request.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isEmpty()) {
            return xff.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

record LoginRequest(String username, String password) {}
record LoginResponse(String token) {}
record ErrorResponse(String error) {}

The constant-time behavior will be ensured by performing a hash comparison even when the user does not exist, which requires that the dummy hash use the same cost factor as the existing bcrypt hash.

If our existing passwords use a cost factor of 12, our dummy hash should use a cost factor of 12 as well.

One small detail to note: timing of a database lookup can potentially give away information too. For a larger user table in our database, a lookup on a user that does not exist might actually take less time than a lookup on a user that exists! Paranoid programmers might wish to add a small delay or ensure that the database paths are identical in both situations.

Frontend Session Handling

Client-side session management must avoid exposing authentication state:

interface LoginCredentials {
    username: string;
    password: string;
}

interface UserProfile {
    id: string;
    username: string;
    email: string;
}

async function login(credentials: LoginCredentials): Promise<void> {
    try {
        const response = await fetch('/api/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'include',
            body: JSON.stringify(credentials)
        });

        if (!response.ok) {
            const data = await response.json();
            showError(data.error || 'Invalid credentials');
            return;
        }

        // Server sets HttpOnly cookie - we never touch the token directly
        window.location.href = '/dashboard';

    } catch (error) {
        showError('Unable to connect. Please try again.');
    }
}

async function fetchUserData(): Promise<UserProfile | null> {
    try {
        const response = await fetch('/api/user/profile', {
            method: 'GET',
            credentials: 'include'
        });

        if (response.status === 401) {
            window.location.href = '/login';
            return null;
        }

        if (!response.ok) {
            throw new Error('Failed to fetch profile');
        }

        return await response.json();
    } catch (error) {
        console.error('Failed to fetch user data:', error);
        return null;
    }
}

function showError(message: string): void {
    // Implementation depends on UI framework
    console.error(message);
}

A few things to notice here. Never store tokens in localStorage; localStorage is accessible to all JavaScript running on the site, so an XSS attack will trivially gain access to all tokens. XSS = full account compromise. Use HttpOnly cookies instead; JavaScript can't access those.

Properly configure your cookies on the server side:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;

@Configuration
public class SessionConfig {

    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer serializer = new DefaultCookieSerializer();
        serializer.setCookieName("SESSIONID");
        serializer.setUseHttpOnlyCookie(true);
        serializer.setUseSecureCookie(true);
        serializer.setSameSite("Strict");
        serializer.setCookieMaxAge(3600);
        return serializer;
    }
}

The SameSite=Strict setting prevents the browser from sending cookies on cross-origin requests, which stops most CSRF attacks. Use Lax if you need cookies sent on top-level navigations (like clicking a link from an email), but Strict is safer when you can get away with it.

Wrapping Up Part 1

In this first part, the fundamental vulnerabilities that are common to authentication schemes have been discussed.

Password Requirements. The weakness of traditional password requirements is that they attempt to optimize the wrong attribute. Length is more important than complexity, and checking against breach databases effectively negates the vast majority of the risk from password stuffing. NIST SP 800-63B simply codifies the knowledge security professionals have recognized for several years: making passwords rotate on every login or enforcing complexity makes passwords weaker, not stronger.

Rate Limiting. Excessive login attempts will turn your login endpoint into a password cracker for attackers. Two forms of rate limiting should be implemented: account rate limiting and IP rate limiting with exponential backoff. Redis will be necessary to provide state for this process to be replicated across multiple application instances.

Username Enumeration. The use of verbose error messages and time differences makes it possible to discover accounts. The use of constant-time authentication, such as dummy hash computations for nonexistent accounts, prevents timing attacks. The use of generic error messages prevents enumeration.

These three vulnerabilities are the foundation on which the security of authentication is based. If you get them wrong, everything else that you build on them is essentially flawed.

Authentication Vulnerabilities

Part 1 of 4

Hands-on guide to authentication vulnerabilities in Java and Spring applications. Covers password security, rate limiting, credential transmission, session management, and cryptographic storage with Spring Security 6 and Java 21. Includes a security architect's perspective on threat modeling, compliance, and building authentication security programs.

Up next

Authentication Vulnerabilities in Java: Credential Transmission & Password Reset (Part 2)

In Part 1, we have already discussed the password policies based on the guidelines provided by the NIST, rate limiting for preventing brute-force attacks, and preventing username enumeration through c

More from this blog

S

SecurityDepth | Application Security for Developers & Security Engineers

15 posts

Practical application security insights for developers and security engineers. Hands-on guides covering web vulnerabilities, secure coding, and modern security practices.