This vulnerability occurs when the mechanism for users to recover or reset forgotten passwords is insecure. Common weaknesses include:
Predictable Reset Tokens: Generating password reset tokens that are short, based on guessable information (like username or timestamp), or have insufficient randomness, allowing attackers to predict or brute-force them.
Token Transmission over Insecure Channels: Sending reset tokens or temporary passwords via unencrypted email (HTTP links) or SMS.
Information Leakage: The recovery process reveals whether a username or email exists in the system (“User Enumeration”).
Weak Security Questions: Relying on easily guessable or publicly available answers to “secret questions”.
Token Reuse/No Expiry: Reset tokens do not expire or can be reused after the password has been reset.
Not Invalidating Other Sessions: Failing to log the user out of other active sessions after a password reset. 🤔❓🔑
Password recovery typically involves generating a secure, random, single-use, time-limited token, sending it to the user via a secure channel (usually email with an HTTPS link), and requiring the user to present that token to set a new password. Frameworks often provide built-in modules for this.Key Remediation Principles:
Use Strong Tokens: Generate long (e.g., 32+ bytes), cryptographically random tokens (CSPRNG). Store a hash of the token in the database, not the token itself.
Set Token Expiry: Tokens should expire after a short period (e.g., 15-60 minutes). Store the expiry timestamp with the token hash.
Single Use: Invalidate the token immediately after it is successfully used.
Secure Transmission: Send reset links via email using HTTPS URLs. Avoid sending temporary passwords directly.
Avoid User Enumeration: Respond with a generic message like “If an account exists for this email, a reset link has been sent” regardless of whether the email was found.
Avoid Weak Security Questions: Do not use easily guessable questions. Prefer token-based email/SMS reset.
Invalidate Sessions: Log the user out of all other active sessions upon successful password reset.
Django: Use the built-in authentication views (PasswordResetView, PasswordResetConfirmView, etc.) which rely on django.contrib.auth.tokens.PasswordResetTokenGenerator. This generator creates secure, time-limited, single-use tokens based on user state and the application’s SECRET_KEY.
Review the token generation logic. Is it using cryptographically secure randomness (secrets.token_urlsafe) or a robust framework mechanism (like Django’s)? Check token length and expiry. Attempt to reuse a reset token after successfully resetting the password. Check the reset link email for http:// vs https://. Test the “forgot password” form with known and unknown email addresses; verify the response message is generic and doesn’t reveal account existence.
Vulnerable Scenario 1: Token Based on Timestamp/UserID
// service/PasswordResetService.javaimport java.time.Instant;import java.util.UUID; // UUIDs are not cryptographically random enough alonepublic String generateWeakToken(User user) { // DANGEROUS: Combining predictable elements. Version 1 UUIDs are time-based. // Even Version 4 UUIDs are not designed as secure tokens. // Needs a cryptographically secure random source. long timestamp = Instant.now().toEpochMilli(); String simpleToken = user.getId() + ":" + timestamp + ":" + UUID.randomUUID().toString(); // Assume this is maybe hashed weakly or stored directly return simpleToken; // Or Base64.getEncoder().encodeToString(simpleToken.getBytes())}
// service/PasswordResetService.java (Secure Token Generation)import java.security.MessageDigest;import java.security.NoSuchAlgorithmException;import java.security.SecureRandom;import java.time.Instant;import java.util.Base64;// Assume PasswordResetToken entity/repository exists@Servicepublic class PasswordResetService { private SecureRandom secureRandom = new SecureRandom(); private static final long EXPIRY_DURATION_MINUTES = 30; public String generateAndStoreResetToken(User user) throws NoSuchAlgorithmException { // SECURE: Generate cryptographically random bytes. byte[] randomBytes = new byte[32]; // 32 bytes = 256 bits secureRandom.nextBytes(randomBytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); // SECURE: Hash the token before storing. MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashedTokenBytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); String tokenHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashedTokenBytes); // SECURE: Store hash, user ID, and expiry. Instant expiry = Instant.now().plus(Duration.ofMinutes(EXPIRY_DURATION_MINUTES)); PasswordResetToken resetToken = new PasswordResetToken(); resetToken.setUserId(user.getId()); resetToken.setTokenHash(tokenHash); resetToken.setExpiryDate(expiry); passwordResetTokenRepository.save(resetToken); // Save to DB // Return the *original* token (not the hash) to be sent to the user return token; } public boolean validateResetToken(String receivedToken, /* user context */) { // 1. Hash the received token using SHA-256 // 2. Find the tokenHash in the database for the relevant user // 3. SECURE: Compare hashes using constant-time comparison (MessageDigest.isEqual()) // 4. Check if the retrieved token record is expired (expiryDate vs Instant.now()) // 5. If valid & not expired, allow reset and DELETE the token record from DB. // ... implementation ... return isValid; }}
Request multiple password resets and examine the generated tokens. Are they long, random-looking strings? Do they change each time? Check the expiry time. Try using an expired token. Try using the same token twice. Check email links use HTTPS. Test the “forgot password” form with valid/invalid emails and verify generic responses (no user enumeration). Avoid security questions altogether.
Use Identity Defaults: Rely on UserManager.GeneratePasswordResetTokenAsync() and UserManager.ResetPasswordAsync(). These use Identity’s secure, time-limited, signed token generation mechanism (DPAPI based).
Avoid User Enumeration: Always return a generic success message on the “Forgot Password” submission page, regardless of whether the user exists.
// Pages/Account/ForgotPassword.cshtml.cs (Secure - Using Identity)using Microsoft.AspNetCore.Identity; // Added namespacesusing Microsoft.AspNetCore.Mvc;using Microsoft.AspNetCore.Mvc.RazorPages;using System.Threading.Tasks;// Assume InputModel with Email, UserManager injectedpublic class ForgotPasswordModel : PageModel{ private readonly UserManager<IdentityUser> _userManager; // Use your user type // Assume IEmailSender _emailSender injected [BindProperty] public InputModel Input { get; set; } // Assume has Email property public async Task<IActionResult> OnPostAsync() { if (ModelState.IsValid) { var user = await _userManager.FindByEmailAsync(Input.Email); // SECURE: Check for user but proceed to confirmation regardless // to prevent user enumeration. Only send email if user is valid. if (user != null && await _userManager.IsEmailConfirmedAsync(user)) { // SECURE: Generate token using Identity's secure provider. var token = await _userManager.GeneratePasswordResetTokenAsync(user); // Ensure token encoding is URL safe if passing directly in URL // var code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); var callbackUrl = Url.Page( "/Account/ResetPassword", // Your reset password page pageHandler: null, values: new { area = "Identity", userId = user.Id, code = token }, // Pass token protocol: Request.Scheme); // Send email using HTTPS link await _emailSender.SendEmailAsync( Input.Email, "Reset Password", $"Please reset your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."); } // SECURE: Always redirect to confirmation page to prevent enumeration. return RedirectToPage("./ForgotPasswordConfirmation"); // Generic confirmation page } return Page(); } // InputModel definition...}// Pages/Account/ResetPassword.cshtml.cs (Secure - Using Identity)public async Task<IActionResult> OnPostAsync(){ if (!ModelState.IsValid) return Page(); var user = await _userManager.FindByIdAsync(Input.UserId); // Assuming UserId is bound if (user == null) { // Don't reveal user doesn't exist, redirect to confirmation return RedirectToPage("./ResetPasswordConfirmation"); } // SECURE: ResetPasswordAsync validates the token, checks expiry, and invalidates it. var result = await _userManager.ResetPasswordAsync(user, Input.Code, Input.Password); if (result.Succeeded) { // SECURE: Optionally, invalidate user's sessions here // await _signInManager.SignOutAsync(); // Example if applicable // await _userManager.UpdateSecurityStampAsync(user); // Helps invalidate cookies return RedirectToPage("./ResetPasswordConfirmation"); } foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } return Page();}
Request password resets. Check token format (should be long, random-looking). Check expiry. Try reusing tokens. Verify email links use HTTPS. Test forgot password form with known/unknown emails and confirm generic responses. Ensure successful reset invalidates the token and optionally other sessions.
// app/Services/PasswordResetService.phpuse Illuminate\Support\Str; // Str::random is good, but example uses weak sourcepublic function createToken(User $user) { // DANGEROUS: Using something predictable like microtime or weak random source. $token = hash('sha256', $user->email . microtime() . rand(1, 100000)); // Store token directly (or weakly hashed) with short/no expiry DB::table('password_resets')->insert([ 'email' => $user->email, 'token' => $token, // Storing raw token is less ideal than hash 'created_at' => now() ]); return $token;}
Laravel: Use the built-in CanResetPassword trait and PasswordBroker. It handles secure token generation (using Str::random), hashing tokens before storage, expiry checks, and provides generic responses by default (via PasswordResetServiceProvider).
Ensure reset email links use HTTPS (APP_URL in .env).
// Using Laravel's Built-in Flow (Secure by default)// 1. Ensure User model uses CanResetPassword trait: use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Contracts\Auth\CanResetPassword; // Interface use Illuminate\Auth\Passwords\CanResetPassword as CanResetPasswordTrait; // Trait class User extends Authenticatable implements CanResetPassword { use Notifiable, CanResetPasswordTrait; ... }// 2. Use default Auth controllers (ForgotPasswordController, ResetPasswordController)// or ensure custom controllers call the Broker correctly.// Auth::routes() includes these.// 3. Ensure email view uses HTTPS:// config/app.php or .env should have correct APP_URL starting with https://// 4. Ensure ForgotPasswordController returns generic response (default behavior):// app/Http/Controllers/Auth/ForgotPasswordController.php protected function sendResetLinkResponse(Request $request, $response) { // Default returns view/redirect with generic status message return back()->with('status', trans($response)); } protected function sendResetLinkFailedResponse(Request $request, $response) { // Default *also* returns view/redirect with generic status message, // using the same 'status' key but different translation string. // This prevents enumeration based on response type/key. return back()->with('status', trans($response)); // Changed from withErrors }// NOTE: Check your Laravel version's exact default behavior. The goal is a generic success-style response always.
// services/authService.jsconst crypto = require('crypto');function generateWeakResetToken(user) { // DANGEROUS: Using Math.random() or non-crypto functions. const pseudoRandom = Math.random().toString(36).substring(2); // DANGEROUS: Based only on time and user ID. const predictablePart = user.id + Date.now(); const token = crypto.createHash('sha1').update(predictablePart + pseudoRandom).digest('hex'); // Store token with short/no expiry... return token;}
Check token generation uses crypto.randomBytes. Check storage uses hashes and expiry. Test token reuse and expiry. Verify email links use HTTPS. Test user enumeration on forgot password form.
Older Devise versions or custom controllers might have leaked user existence. Modern Devise defaults are generally safe. Check Devise configuration for config.paranoid = true (enables timing attacks if false, but prevents enumeration). The default response is usually generic.
Devise: Use the built-in user.send_reset_password_instructions method. It uses Devise.token_generator which creates secure, signed, time-limited tokens. Ensure Devise is configured to return generic messages (config.paranoid implications).
Custom: Generate tokens using SecureRandom.urlsafe_base64(32) or similar. Store a digest (Digest::SHA256.hexdigest) of the token with an expiry. Compare using ActiveSupport::SecurityUtils.secure_compare.
# app/controllers/passwords_controller.rb (Using Devise Securely)class PasswordsController < Devise::PasswordsController # Devise controller handles secure token generation, email sending, # generic responses, token validation, and expiry automatically. # Ensure Devise initializer (config/initializers/devise.rb) is secure. # Ensure mailer templates (app/views/devise/mailer/) use HTTPS links. # Example: edit_password_url(@resource, reset_password_token: @token) should generate HTTPS URL. # Overriding response to ensure generic message (Usually default in modern Devise) # protected function after_sending_reset_password_instructions_path_for(resource_name) # '/users/password/sent' # Redirect to a generic confirmation page # endend# Custom Token Generation (Secure - If not using Devise)# app/models/user.rbdef generate_secure_reset_token! # SECURE: Generate strong random token raw_token = SecureRandom.urlsafe_base64(32) # SECURE: Store only the hash and expiry token_hash = Digest::SHA256.hexdigest(raw_token) expiry = 30.minutes.from_now # Example expiry self.update!(reset_token_hash: token_hash, reset_sent_at: Time.zone.now, reset_expiry: expiry) raw_token # Return raw token to send via emailenddef self.find_by_reset_token(received_token) return nil if received_token.blank? received_hash = Digest::SHA256.hexdigest(received_token) # SECURE: Find by hash and check expiry user = User.find_by(reset_token_hash: received_hash) # SECURE: Check expiry and use constant time compare just in case (belt-and-suspenders) if user && user.reset_expiry.present? && Time.zone.now < user.reset_expiry && \ ActiveSupport::SecurityUtils.secure_compare(user.reset_token_hash, received_hash) user else nil endend# Remember to nil out reset_token_hash and reset_expiry after successful use.
Check token generation (Devise default is good, check custom logic for SecureRandom). Check storage uses hashes/digests. Test expiry and reuse. Verify email links use HTTPS. Test forgot password form for generic responses (no enumeration).