Skip to main content

Command Palette

Search for a command to run...

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

Updated
19 min read
Authentication Vulnerabilities in Java: Credential Transmission & Password Reset (Part 2)
M

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

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 constant-time operations and generic error messages.

In this second part, we will find an explanation about the secure transmission of credentials, common password reset weaknesses that allow account compromise, and multi-stage authentication bypass techniques.

1. Insecure Credential Transmission: The Transport Layer Problem

Credentials sent over the wire using unencrypted HTTP will be sent in plaintext. Any person who has access to the network connection from the client to the server will be able to sniff the credentials sent over the network using packet sniffers like Wireshark.

Another common misconception is that “we use HTTPS on our login page.” Well, that's not enough. In fact, if any page on the domain uses HTTP, an attacker can inject malicious JavaScript that submits credentials to an HTTP endpoint or exfiltrates them before the form submits. Mixed content is a security hole.

The HTTPS Requirement

HTTPS is a non-negotiable requirement when it comes to authentication, as it gives us confidentiality, as our credentials cannot be intercepted; integrity, as our credentials cannot be modified in transit; and server authentication, as the client knows it’s the server they are dealing with, rather than someone performing a MITM attack.

Secure configuration (Spring Security):

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.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class TransportSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .requiresChannel(channel -> channel.anyRequest().requiresSecure()
            )
            .headers(headers -> headers
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .preload(true)
                    .maxAgeInSeconds(31536000)
                )
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; " +
                            "script-src 'self'; " +
                            "style-src 'self' 'unsafe-inline';")
                )
                .frameOptions(frame -> frame.deny())
            );

        return http.build();
    }
}

HSTS (HTTP Strict Transport Security) protects against protocol downgrade attacks. Once a browser has received an HSTS header from a server, the browser will not make HTTP requests to the same domain for the specified period of time; the protocol upgrade from HTTP to HTTPS occurs within the browser before the HTTP request is ever sent from the computer. The preload directive enables inclusion in browser preload lists; browsers ship with a hardcoded list of domains that should always use HTTPS, so even the first request is protected.

One thing to note is that it’s difficult to disable HSTS once enabled. The preload lists are not frequently updated, so it may take months to disable the inclusion of our domain.

Infrastructure-Level Enforcement

Enforce HTTPS at the infrastructure layer as defense in depth:

# Nginx configuration
server {
    listen 80;
    server_name example.com;
    return 301 https://\(server_name\)request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /path/to/certificate.crt;
    ssl_certificate_key /path/to/private.key;

    # Use modern TLS protocols only - TLS 1.0 and 1.1 are deprecated
    ssl_protocols TLSv1.2 TLSv1.3;

    # Strong cipher suites - this list is opinionated, adjust for your needs
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
    ssl_prefer_server_ciphers off;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The X-Forwarded-Proto header tells backend what protocol the original request used. This is important when app is behind a reverse proxy - without it, application thinks all requests are HTTP and might generate incorrect redirect URLs or make wrong security decisions.

React Application Security Headers

We need to configure API client to work with secure cookies:

import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';

interface ApiError {
    error: string;
    status?: number;
}

const apiClient: AxiosInstance = axios.create({
    baseURL: 'https://api.example.com',
    timeout: 10000,
    withCredentials: true,
    headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest'
    }
});

apiClient.interceptors.request.use(
    (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
        const csrfToken = document.querySelector('meta[name="csrf-token"]')
            ?.getAttribute('content');
        if (csrfToken && config.headers) {
            config.headers['X-CSRF-Token'] = csrfToken;
        }
        return config;
    },
    (error: AxiosError): Promise<never> => Promise.reject(error)
);

apiClient.interceptors.response.use(
    (response: AxiosResponse): AxiosResponse => response,
    (error: AxiosError<ApiError>): Promise<never> => {
        if (error.response?.status === 401) {
            window.location.href = '/login';
        }
        return Promise.reject(error);
    }
);

export default apiClient;

The withCredentials: true setting is necessary for cookies to be sent on cross-origin requests. If our API is on a different subdomain than our frontend, we'll need this plus appropriate CORS configuration on the backend. Without it, cookies silently don't get sent and we spend hours debugging why authentication "randomly" fails.

2. Insecure Password Reset: The Account Takeover Vector

In every authentication system, the password reset function has consistently remained the weakest link. It is the paradox of authentication: the need to authenticate the user who, by the very nature of the request, has forgotten their authentication information.

The usual flow here is sending a password reset email that contains a token that will allow a user to have temporary access to a system. However, when a token is weak, predictable, reusable, or long-lived, it effectively becomes a way to take over a user’s account.

Common Password Reset Vulnerabilities

Enumeration is a case when the system provides varying responses for existing and non-existing accounts. The attacker can immediately identify which emails are registered by comparing the two differing responses: "We've sent a reset link to your email" and "No account found with that email."

Weak token generation refers to the use of sequential IDs, timestamp-based tokens, or short random strings as tokens. If attacker is able to predict or brute-force token, then the account is his.

Token reuse indicates that a token can be reused more than once. For instance, in this problem, attacker can intercept a reset email (email is not encrypted in transit), and he can reuse link from email even after the user has reset their password.

No expiration simply means that the token will remain valid indefinitely, which in turn means that that reset link from 6 months ago will continue to function correctly.

Token leakage happens whenever tokens are found in the server logs, referrer headers, or browser history. If the URL used in the reset process is https://example.com/reset?token=abc123, and the user clicks a link on the reset page, that token might leak to the linked site via the Referer header.

No rate limiting enables an attacker to make an arbitrary number of reset requests. This allows an attacker to carry out brute-force attacks against short tokens.

Vulnerable: Weak Token Generation

import java.util.UUID;

@Service
public class VulnerablePasswordResetService {

    public String createResetToken(User user) {
        // UUID is not cryptographically secure for this purpose
        // UUIDs are predictable in some implementations
        String token = UUID.randomUUID().toString();

        // Token stored in plaintext
        user.setResetToken(token);
        user.setResetTokenExpiry(null);  // No expiration
        userRepository.save(user);

        return token;
    }

    public boolean resetPassword(String token, String newPassword) {
        User user = userRepository.findByResetToken(token);

        if (user == null) {
            return false;
        }

        // Token can be reused - not cleared after use
        user.setPasswordHash(passwordEncoder.encode(newPassword));
        userRepository.save(user);

        return true;
    }
}

Multiple problems here: UUID v4 is technically random but not designed for security tokens (some implementations are predictable). Token stored in plaintext (database compromise reveals all pending reset tokens). No expiration check. Token not cleared after use.

Secure: Cryptographic Reset Tokens

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;

@Service
public class SecurePasswordResetService {

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

    private static final int TOKEN_BYTES = 32;
    private static final int TOKEN_EXPIRY_MINUTES = 30;
    private static final int MAX_RESET_ATTEMPTS_PER_DAY = 5;

    private final SecureRandom secureRandom = new SecureRandom();
    private final PasswordResetTokenRepository tokenRepository;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final PasswordValidationService passwordValidationService;
    private final SessionRevocationService sessionRevocationService;
    private final EmailService emailService;

    public SecurePasswordResetService(
      PasswordResetTokenRepository tokenRepository,
      UserRepository userRepository,
      PasswordEncoder passwordEncoder,
      PasswordValidationService passwordValidationService,
      SessionRevocationService sessionRevocationService,
      EmailService emailService) {
        this.tokenRepository = tokenRepository;
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.passwordValidationService = passwordValidationService;
        this.sessionRevocationService = sessionRevocationService;
        this.emailService = emailService;
    }

    @Transactional
    public void initiatePasswordReset(String email, String ipAddress) {
        Optional<User> userOpt = userRepository.findByEmail(email.toLowerCase());

        // Always respond the same way to prevent enumeration
        if (userOpt.isEmpty()) {
            logger.debug("Password reset requested for non-existent email");
            simulateProcessingDelay();
            return;
        }

        User user = userOpt.get();

        // Rate limit reset requests
        int recentAttempts = tokenRepository.countRecentAttempts(
                user.getId(),
                Instant.now().minus(24, ChronoUnit.HOURS));

        if (recentAttempts >= MAX_RESET_ATTEMPTS_PER_DAY) {
            logger.warn("Rate limit exceeded for password reset: userId={}, ip={}", user.getId(), ipAddress);
            return;
        }

        // Invalidate any existing tokens
        tokenRepository.invalidateExistingTokens(user.getId());

        // Generate cryptographically secure token
        byte[] tokenBytes = new byte[TOKEN_BYTES];
        secureRandom.nextBytes(tokenBytes);
        String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);

        // Store only the hash of the token
        String tokenHash = hashToken(rawToken);

        PasswordResetToken resetToken = new PasswordResetToken(
                user.getId(),
                tokenHash,
                Instant.now().plus(TOKEN_EXPIRY_MINUTES, ChronoUnit.MINUTES),
                ipAddress
        );
        tokenRepository.save(resetToken);

        // Send email with raw token (hash is in database)
        String resetLink = "https://example.com/reset-password?token=" + rawToken;
        emailService.sendPasswordResetEmail(user.getEmail(), resetLink);

        logger.info("Password reset initiated for userId={} from ip={}", user.getId(), ipAddress);
    }

    @Transactional
    public PasswordResetResult resetPassword(String rawToken, String newPassword) {
        String tokenHash = hashToken(rawToken);

        Optional<PasswordResetToken> tokenOpt = tokenRepository.findByTokenHash(tokenHash);

        if (tokenOpt.isEmpty()) {
            logger.warn("Password reset attempted with invalid token");
            return PasswordResetResult.INVALID_TOKEN;
        }

        PasswordResetToken resetToken = tokenOpt.get();

        if (resetToken.isUsed()) {
            logger.warn("Password reset attempted with already-used token");
            return PasswordResetResult.TOKEN_ALREADY_USED;
        }

        if (Instant.now().isAfter(resetToken.getExpiresAt())) {
            logger.warn("Password reset attempted with expired token");
            return PasswordResetResult.TOKEN_EXPIRED;
        }

        User user = userRepository.findById(resetToken.getUserId())
                .orElseThrow(() -> new IllegalStateException("User not found for valid token"));

        // Validate new password
        ValidationResult validation = passwordValidationService.validatePassword(newPassword, user);
        if (!validation.isValid()) {
            return PasswordResetResult.INVALID_PASSWORD;
        }

        // Update password
        user.setPasswordHash(passwordEncoder.encode(newPassword));
        user.setPasswordChangedDate(Instant.now());
        userRepository.save(user);

        // Mark token as used (single use)
        resetToken.markAsUsed();
        tokenRepository.save(resetToken);

        // Revoke all existing sessions
        sessionRevocationService.revokeAllUserSessions(user.getId());

        logger.info("Password successfully reset for userId={}", user.getId());
        return PasswordResetResult.SUCCESS;
    }

    private String hashToken(String token) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }

    private void simulateProcessingDelay() {
        try {
            Thread.sleep(100 + secureRandom.nextInt(100));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

enum PasswordResetResult {
    SUCCESS,
    INVALID_TOKEN,
    TOKEN_ALREADY_USED,
    TOKEN_EXPIRED,
    INVALID_PASSWORD
}

Supporting classes:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.time.Instant;

@Entity
@Table(name = "password_reset_tokens")
public class PasswordResetToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "token_hash", nullable = false, unique = true)
    private String tokenHash;

    @Column(name = "expires_at", nullable = false)
    private Instant expiresAt;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    @Column(name = "used_at")
    private Instant usedAt;

    @Column(name = "request_ip")
    private String requestIp;

    protected PasswordResetToken() {}

    public PasswordResetToken(Long userId, String tokenHash, Instant expiresAt, String requestIp) {
        this.userId = userId;
        this.tokenHash = tokenHash;
        this.expiresAt = expiresAt;
        this.createdAt = Instant.now();
        this.requestIp = requestIp;
    }

    public Long getId() { return id; }
    public Long getUserId() { return userId; }
    public String getTokenHash() { return tokenHash; }
    public Instant getExpiresAt() { return expiresAt; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUsedAt() { return usedAt; }
    public String getRequestIp() { return requestIp; }

    public boolean isUsed() {
        return usedAt != null;
    }

    public void markAsUsed() {
        this.usedAt = Instant.now();
    }
}
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

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

public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {

    Optional<PasswordResetToken> findByTokenHash(String tokenHash);

    @Query("SELECT COUNT(t) FROM PasswordResetToken t WHERE t.userId = :userId AND t.createdAt > :since")
    int countRecentAttempts(@Param("userId") Long userId, @Param("since") Instant since);

    @Modifying
    @Query("UPDATE PasswordResetToken t SET t.usedAt = CURRENT_TIMESTAMP WHERE t.userId = :userId AND t.usedAt IS NULL")
    void invalidateExistingTokens(@Param("userId") Long userId);

    @Modifying
    @Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now")
    void deleteExpiredTokens(@Param("now") Instant now);
}

Important security attributes: Entropy level - 256 bits, generated with SecureRandom, cryptographically strong. Token is hashed before storage, so if database is breached, valid tokens are not revealed. Token expires after 30 minutes and token is only used once. Rate limiting at 5 requests per day. All sessions are revoked on password change. Time to response is the same if the email does not exist.

Session Revocation Service

When a password is reset, all existing sessions must be invalidated:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Service;

import java.util.Map;

@Service
public class SessionRevocationService {

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

    private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;

    public SessionRevocationService(FindByIndexNameSessionRepository<? extends Session> sessionRepository) {
        this.sessionRepository = sessionRepository;
    }

    public void revokeAllUserSessions(Long userId) {
        String principalName = userId.toString();

        Map<String, ? extends Session> sessions = sessionRepository
                .findByPrincipalName(principalName);

        for (String sessionId : sessions.keySet()) {
            sessionRepository.deleteById(sessionId);
        }

        logger.info("Revoked {} sessions for userId={}", sessions.size(), userId);
    }

    public void revokeAllSessionsExceptCurrent(Long userId, String currentSessionId) {
        String principalName = userId.toString();

        Map<String, ? extends Session> sessions = sessionRepository
                .findByPrincipalName(principalName);

        int revokedCount = 0;
        for (String sessionId : sessions.keySet()) {
            if (!sessionId.equals(currentSessionId)) {
                sessionRepository.deleteById(sessionId);
                revokedCount++;
            }
        }

        logger.info("Revoked {} sessions for userId={} (kept current)", revokedCount, userId);
    }
}

3. Multi-Stage Authentication Bypass

Additionally, multi-factor authentication (MFA), which should prevent access to accounts in case of a breach of authentication information, can fail to function as expected in various implementations where MFA is not mandatory and can be bypassed altogether.

Vulnerable: Premature Session Creation

@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request,
                               HttpSession session) {

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

    if (user == null || !passwordEncoder.matches(request.password(), user.getPasswordHash())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "Invalid credentials"));
    }

    // VULNERABLE: Session created before MFA verification
    session.setAttribute("user", user);
    session.setAttribute("authenticated", true);

    if (user.isMfaEnabled()) {
        session.setAttribute("mfaPending", true);
        return ResponseEntity.ok(Map.of("status", "mfa_required"));
    }

    return ResponseEntity.ok(Map.of("status", "authenticated"));
}

@PostMapping("/api/auth/verify-mfa")
public ResponseEntity<?> verifyMfa(@RequestBody MfaRequest request,
                                   HttpSession session) {

    User user = (User) session.getAttribute("user");
    if (user == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    if (totpService.verifyCode(user.getTotpSecret(), request.code())) {
        session.removeAttribute("mfaPending");
        return ResponseEntity.ok(Map.of("status", "authenticated"));
    }

    return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(Map.of("error", "Invalid code"));
}

The vulnerability: after password verification, the user is placed in the session with authenticated=true. An attacker who captures the session cookie after step 1 has an authenticated session—they can simply skip step 2. The mfaPending flag is just a suggestion that can be ignored.

Secure: Temporary Authentication State

MFA requires strict state separation. Password verification grants no access—only a temporary state that allows MFA verification. The real session is created only after all factors are verified.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
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;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.Map;

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

    private static final Logger logger = LoggerFactory.getLogger(MultiStageAuthController.class);
    private static final Duration TEMP_AUTH_TTL = Duration.ofMinutes(5);
    private static final int MAX_OTP_ATTEMPTS = 3;
    private static final String DUMMY_HASH =
            "\(2a\)12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.AQPVsBXe3MZxlm";

    private final SecureRandom secureRandom = new SecureRandom();
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TotpService totpService;
    private final RedisTemplate<String, TempAuthState> redisTemplate;
    private final JwtService jwtService;
    private final AuthenticationThrottleService throttleService;

    public MultiStageAuthController(UserRepository userRepository,
                                    PasswordEncoder passwordEncoder,
                                    TotpService totpService,
                                    RedisTemplate<String, TempAuthState> redisTemplate,
                                    JwtService jwtService,
                                    AuthenticationThrottleService throttleService) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.totpService = totpService;
        this.redisTemplate = redisTemplate;
        this.jwtService = jwtService;
        this.throttleService = throttleService;
    }

    @PostMapping("/login-step1")
    public ResponseEntity<Map<String, Object>> loginStep1(
            @RequestBody LoginRequest request,
            HttpServletRequest httpRequest) {

        String ipAddress = getClientIp(httpRequest);

        // Check rate limiting
        ThrottleResult throttle = throttleService.checkThrottle(request.username(), ipAddress);
        if (!throttle.isAllowed()) {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                    .body(Map.of("error", "Too many attempts. Please try again later."));
        }

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

        if (user != null) {
            authenticated = passwordEncoder.matches(request.password(), user.getPasswordHash());
        } else {
            passwordEncoder.matches(request.password(), DUMMY_HASH);
        }

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

        if (!authenticated) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Invalid credentials"));
        }

        if (!user.isMfaEnabled()) {
            // No MFA - create session directly
            throttleService.resetFailures(request.username(), ipAddress);
            String token = jwtService.generateToken(user);
            return ResponseEntity.ok(Map.of(
                    "status", "authenticated",
                    "token", token
            ));
        }

        // MFA enabled - create temporary state
        String tempAuthId = generateTempAuthId();

        TempAuthState tempState = new TempAuthState(
                user.getId(),
                user.getUsername(),
                ipAddress,
                System.currentTimeMillis() + TEMP_AUTH_TTL.toMillis()
        );

        redisTemplate.opsForValue().set(
                "temp_auth:" + tempAuthId,
                tempState,
                TEMP_AUTH_TTL
        );

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

        return ResponseEntity.ok(Map.of(
                "status", "mfa_required",
                "tempAuthId", tempAuthId
        ));
    }

    @PostMapping("/login-step2-mfa")
    public ResponseEntity<Map<String, Object>> loginStep2Mfa(
            @RequestBody MfaRequest request,
            HttpServletRequest httpRequest) {

        String key = "temp_auth:" + request.tempAuthId();
        TempAuthState tempState = redisTemplate.opsForValue().get(key);

        if (tempState == null) {
            logger.warn("MFA verification attempted with invalid/expired tempAuthId");
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Session expired. Please login again."));
        }

        if (tempState.isExpired()) {
            redisTemplate.delete(key);
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Session expired. Please login again."));
        }

        // Check OTP attempt limit
        if (tempState.hasExceededOtpAttempts(MAX_OTP_ATTEMPTS)) {
            redisTemplate.delete(key);
            logger.warn("OTP attempt limit exceeded for user: {}", tempState.getUsername());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Too many failed attempts. Please login again."));
        }

        User user = userRepository.findById(tempState.getUserId())
                .orElseThrow(() -> new IllegalStateException("User not found"));

        if (!totpService.verifyCode(user.getTotpSecret(), request.otp())) {
            tempState.incrementOtpAttempts();
            redisTemplate.opsForValue().set(key, tempState, TEMP_AUTH_TTL);

            logger.warn("Invalid OTP for user: {} (attempt {})",
                    user.getUsername(), tempState.getOtpAttempts());

            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("error", "Invalid verification code"));
        }

        // Delete temp state immediately - single use
        redisTemplate.delete(key);

        // Reset throttle on successful authentication
        throttleService.resetFailures(user.getUsername(), getClientIp(httpRequest));

        // Create real session
        String token = jwtService.generateToken(user);

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

        return ResponseEntity.ok(Map.of(
                "status", "authenticated",
                "token", token
        ));
    }

    private String generateTempAuthId() {
        byte[] bytes = new byte[32];
        secureRandom.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    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 MfaRequest(String tempAuthId, String otp) {}
import java.io.Serial;
import java.io.Serializable;

public class TempAuthState implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    private final Long userId;
    private final String username;
    private final String ipAddress;
    private final long expiresAt;
    private int otpAttempts;

    public TempAuthState(Long userId, String username, String ipAddress, long expiresAt) {
        this.userId = userId;
        this.username = username;
        this.ipAddress = ipAddress;
        this.expiresAt = expiresAt;
        this.otpAttempts = 0;
    }

    public Long getUserId() { return userId; }
    public String getUsername() { return username; }
    public String getIpAddress() { return ipAddress; }
    public int getOtpAttempts() { return otpAttempts; }

    public boolean isExpired() {
        return System.currentTimeMillis() > expiresAt;
    }

    public void incrementOtpAttempts() {
        this.otpAttempts++;
    }

    public boolean hasExceededOtpAttempts(int max) {
        return otpAttempts >= max;
    }
}

Important notes: password verification does not create a new session. It simply stores a state in Redis. The state is temporary and expires in 5 minutes. The temp ID is random and one-time use. OTP attempts are limited to 3 before the temp state is invalidated. Real session created only after all factors verified.

React Multi-Stage Login Component

import React, { useState, FormEvent, ChangeEvent } from 'react';

type AuthStage = 'password' | 'mfa';

interface LoginFormState {
    stage: AuthStage;
    tempAuthId: string | null;
    username: string;
    password: string;
    otp: string;
    loading: boolean;
    error: string | null;
}

async function loginStage1(username: string, password: string): Promise<string | null> {
    const response = await fetch('/api/auth/login-step1', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
    });

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

    const data = await response.json();
    if (data.status === 'mfa_required') {
        return data.tempAuthId;
    }
    return null;
}

async function loginStage2(tempAuthId: string, otp: string): Promise<boolean> {
    const response = await fetch('/api/auth/login-step2-mfa', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ tempAuthId, otp })
    });

    if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || 'Invalid verification code');
    }

    const data = await response.json();
    return data.status === 'authenticated';
}

export const LoginForm: React.FC = () => {
    const [state, setState] = useState<LoginFormState>({
        stage: 'password',
        tempAuthId: null,
        username: '',
        password: '',
        otp: '',
        loading: false,
        error: null
    });

    const updateState = (updates: Partial<LoginFormState>): void => {
        setState(prev => ({ ...prev, ...updates }));
    };

    const handlePasswordSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
        e.preventDefault();
        updateState({ loading: true, error: null });

        try {
            const tempId = await loginStage1(state.username, state.password);
            if (tempId) {
                updateState({
                    tempAuthId: tempId,
                    stage: 'mfa',
                    password: '',  // Clear password from memory
                    loading: false
                });
            } else {
                // No MFA required, login complete
                window.location.href = '/dashboard';
            }
        } catch (err) {
            updateState({
                error: err instanceof Error ? err.message : 'Login failed',
                loading: false
            });
        }
    };

    const handleMfaSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
        e.preventDefault();
        if (!state.tempAuthId) return;

        updateState({ loading: true, error: null });

        try {
            const success = await loginStage2(state.tempAuthId, state.otp);
            if (success) {
                window.location.href = '/dashboard';
            }
        } catch (err) {
            updateState({
                error: err instanceof Error ? err.message : 'Verification failed',
                otp: '',
                loading: false
            });
        }
    };

    const handleInputChange = (field: keyof LoginFormState) =>
        (e: ChangeEvent<HTMLInputElement>): void => {
            updateState({ [field]: e.target.value });
        };

    if (state.stage === 'mfa') {
        return (
            <form onSubmit={handleMfaSubmit}>
                <p>Enter verification code from your authenticator app</p>
                {state.error && <div className="error">{state.error}</div>}
                <input
                    type="text"
                    value={state.otp}
                    onChange={handleInputChange('otp')}
                    maxLength={6}
                    autoComplete="one-time-code"
                    inputMode="numeric"
                    pattern="[0-9]*"
                    disabled={state.loading}
                    placeholder="000000"
                />
                <button type="submit" disabled={state.loading || state.otp.length !== 6}>
                    {state.loading ? 'Verifying...' : 'Verify'}
                </button>
            </form>
        );
    }

    return (
        <form onSubmit={handlePasswordSubmit}>
            {state.error && <div className="error">{state.error}</div>}
            <input
                type="text"
                value={state.username}
                onChange={handleInputChange('username')}
                autoComplete="username"
                disabled={state.loading}
                placeholder="Username"
            />
            <input
                type="password"
                value={state.password}
                onChange={handleInputChange('password')}
                autoComplete="current-password"
                disabled={state.loading}
                placeholder="Password"
            />
            <button type="submit" disabled={state.loading}>
                {state.loading ? 'Logging in...' : 'Login'}
            </button>
        </form>
    );
};

Note password: '' after moving to the MFA stage. This removes the password from the React state, reducing the attack surface for an XSS attack or an extension attack by minimizing the amount of time that an attacker would have to obtain the password that way. It's one of those little things that add up to defense in depth.

Wrapping Part 2

In this second part, critical weaknesses in credential transmission and password reset have been discussed.

Credential Transmission. HTTPS is not negotiable. HSTS prevents protocol downgrade attacks because the browser enforces the HTTPS protocol. A well-configured cookies (HttpOnly, Secure, SameSite) provides additional defenses against XSS and CSRF. Finally, the infrastructure enforces HTTPS at the nginx level or load balancer level, ensuring that credentials are never sent in the clear text even with flawed application code.

Password Reset. This is consistently the weakest link in auth systems. A secure password reset system would require cryptographically secure tokens with 256-bit entropy from a SecureRandom generator, hashing the tokens in the database so an unauthorized disclosure does not compromise pending resets, one-time usage of the tokens, a short expiration period for the tokens (30 minutes is a good value), rate limits on the number of attempts allowed within a short period of time (5 attempts a day prevent brute force attempts and abuse), revoking the sessions on a password change, and providing generic responses to prevent account enumeration attacks.

Multi-Stage Authentication. Lack of proper state management makes the use of MFA optional instead of mandatory. Never establish authenticated sessions before the completion of the entire authentication process. Implement a temporary authentication state using Redis, which automatically expires. Implement single-use temporary IDs, which expire after verification. Keep the expiration time very short, as 5 minutes is more than ample time for a user to retrieve their phone.

These are the weakest links within the authentication system. The password reset aspect is one of the most commonly missed during security evaluation, as it's not considered "interesting" enough when compared to the primary login flow, yet provides attackers with straightforward ATO paths.

Authentication Vulnerabilities

Part 2 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: Session Management & Advanced Security (Part 3)

In Part 1, we discussed the requirements of passwords according to NIST recommendations, various methods of rate limiting using sliding window and exponential backoff, and ways to avoid enumeration of

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.