Password Recovery (Forgot Password) with AWS Cognito in iOS

In the previous articles, we implemented a complete authentication system with registration, login, session persistence, and automatic token refresh. Now we’ll tackle a critical flow: what happens when a user forgets their password?

What are we building?

In this article, we’ll implement:

  • Request recovery code — User enters email, Cognito sends code
  • Verify code and change password — User enters code + new password
  • ForgotPasswordView — Screen with complete 2-step flow
  • SignInView integration — «Forgot your password?» link
  • Error handling — Incorrect codes, expired, invalid emails

The Forgot Password flow

Complete flow: User forgot password → enters email → Cognito sends code → user enters code + new password → password updated → user logs in with new password

User forgot password
    ↓ 1. Screen: Enter email
    ↓ User enters: jose@example.com
    ↓ App calls: forgotPassword(email)
    ↓ Cognito sends code to email
    ↓ 2. Screen: Enter code + new password
    ↓ App calls: confirmForgotPassword(email, code, newPassword)
    ↓ Password updated
    ↓ User logs in with new password

Prerequisites

  • Have completed previous articles (especially Article 3: Login)
  • Have CognitoAuthService implemented
  • Understand code confirmation flow (similar to Article 2)
  • Registered user with confirmed email

Step 1: Add Recovery Methods in CognitoAuthService

First we add two new methods to the authentication service. Add in CognitoAuthService.swift (after the refreshTokens method):

Method 1: forgotPassword(email:)

// MARK: - Password Recovery (Forgot Password)
/// Requests a password recovery code
func forgotPassword(email: String) async throws -> String {
    guard let client = cognitoClient else {
        throw NSError(domain: "CognitoAuthService", code: -1,
                     userInfo: [NSLocalizedDescriptionKey: "Cognito client not initialized"])
    }
    let forgotInput = ForgotPasswordInput(
        clientId: clientId,
        username: email
    )
    do {
        let response = try await client.forgotPassword(input: forgotInput)
        print("✅ Recovery code sent")
        let destination = response.codeDeliveryDetails?.destination ?? "your email"
        print("Destination: \(destination)")
        return "Recovery code sent to \(destination)"
    } catch {
        print("❌ Error requesting recovery code: \(error)")
        let errorMessage = error.localizedDescription
        if errorMessage.contains("UserNotFoundException") {
            throw NSError(domain: "CognitoAuthService", code: 6001,
                        userInfo: [NSLocalizedDescriptionKey: "User not found"])
        } else if errorMessage.contains("LimitExceededException") {
            throw NSError(domain: "CognitoAuthService", code: 6002,
                        userInfo: [NSLocalizedDescriptionKey: "Too many attempts. Wait a few minutes and try again"])
        } else {
            throw NSError(domain: "CognitoAuthService", code: 6000,
                        userInfo: [NSLocalizedDescriptionKey: "Error requesting code: \(errorMessage)"])
        }
    }
}

Method 2: confirmForgotPassword(email:code:newPassword:)

// MARK: - Confirm Password Recovery
/// Confirms password change with received code
func confirmForgotPassword(email: String, code: String, newPassword: String) async throws -> String {
    guard let client = cognitoClient else {
        throw NSError(domain: "CognitoAuthService", code: -1,
                     userInfo: [NSLocalizedDescriptionKey: "Cognito client not initialized"])
    }
    let confirmInput = ConfirmForgotPasswordInput(
        clientId: clientId,
        confirmationCode: code,
        password: newPassword,
        username: email
    )
    do {
        _ = try await client.confirmForgotPassword(input: confirmInput)
        print("✅ Password updated successfully")
        return "Password updated successfully. You can now sign in with your new password"
    } catch {
        print("❌ Error confirming recovery: \(error)")
        let errorMessage = error.localizedDescription
        if errorMessage.contains("CodeMismatchException") {
            throw NSError(domain: "CognitoAuthService", code: 7001,
                        userInfo: [NSLocalizedDescriptionKey: "Incorrect code. Verify and try again"])
        } else if errorMessage.contains("ExpiredCodeException") {
            throw NSError(domain: "CognitoAuthService", code: 7002,
                        userInfo: [NSLocalizedDescriptionKey: "Code has expired. Request a new one"])
        } else if errorMessage.contains("InvalidPasswordException") {
            throw NSError(domain: "CognitoAuthService", code: 7003,
                        userInfo: [NSLocalizedDescriptionKey: "Password does not meet minimum requirements"])
        } else {
            throw NSError(domain: "CognitoAuthService", code: 7000,
                        userInfo: [NSLocalizedDescriptionKey: "Error confirming recovery: \(errorMessage)"])
        }
    }
}

Step 2: Create ForgotPasswordView

Create file ForgotPasswordView.swift. The view handles two states:

enum ForgotPasswordStep {
    case enterEmail      // Step 1: Request code
    case resetPassword   // Step 2: Enter code + new password
}

Step 1 — Request code: Email field, «Send Code» button, calls authService.forgotPassword(email), on success automatically advances to Step 2.

Step 2 — Change password: Verification code field, new password field, confirm password field, visual password match validation, «Change Password» button, calls authService.confirmForgotPassword(email, code, newPassword), on success returns to SignInView.

Validations:

// Step 1
guard !email.isEmpty else { ... }
// Step 2
guard !code.isEmpty else { ... }
guard !newPassword.isEmpty else { ... }
guard newPassword == confirmPassword else { ... }
guard newPassword.count >= 8 else { ... }

UI/UX features: different icons per step, loading states with ProgressView, status messages, visual indicator for matching passwords, option to go back to previous step.

Step 3: Integrate in SignInView

Add the «Forgot your password?» link in SignInView.swift.

Add navigation state:

@State private var navigateToForgotPassword: Bool = false

Add link in the UI (after password field):

// Link to Forgot Password
HStack {
    Spacer()
    Button(action: {
        navigateToForgotPassword = true
    }) {
        Text("Forgot your password?")
            .font(.footnote)
            .foregroundColor(.blue)
    }
}

Add navigationDestination:

.navigationDestination(isPresented: $navigateToForgotPassword) {
    ForgotPasswordView()
}
ForgotPasswordView step 1 — enter email screen
ForgotPasswordView step 2 — enter code and new password screen

Step 4: Test the Complete Flow

Test 1: Successful flow

Open the app → go to SignInView → press «Forgot your password?» → Step 1: enter jose@example.com → press «Send Code» → console shows «✅ Recovery code sent» → alert: «Recovery code sent to j***@example.com» → view advances to Step 2 → check email → open email from AWS Cognito → subject: «Your verification code» → Step 2: enter code + new password + confirm → press «Change Password» → console: «✅ Password updated successfully» → returns to SignInView → sign in with new password → success.

Test 2: Email doesn’t exist

Enter: doesnotexist@example.com → press «Send Code» → alert: «User not found» ✅ No code was sent.

Test 3: Incorrect code

Request code for valid email → Step 2: enter code 999999 (incorrect) → press «Change Password» → alert: «Incorrect code. Verify and try again» ✅ Password did NOT change.

Test 4: Expired code

Cognito codes expire after 15 minutes. Request code → wait more than 15 minutes → enter expired code → alert: «Code has expired. Request a new one» → press «Go back to request code» → returns to Step 1.

Test 5: Weak password

Enter password that doesn’t meet requirements: new password «abc» (too short, no numbers, no uppercase) → local alert: «Password must be at least 8 characters». If passes local but Cognito rejects: «Password does not meet minimum requirements». Cognito requirements (configurable): minimum 8 characters, at least 1 uppercase, 1 lowercase, 1 number, 1 special character.

Test 6: Passwords don’t match

New password: Password123! / Confirm: Password456! → red indicator: «Passwords do not match» → button disabled.

Test 7: Rate limiting

If you request the code multiple times in a short period: Alert: «Too many attempts. Wait a few minutes and try again»

Rate limiting alert — too many attempts

Conclusion

In this article, we implemented the complete password recovery flow with AWS Cognito. Now users can recover their accounts without needing technical support.

What we achieved:

  • forgotPassword() — Request recovery code
  • confirmForgotPassword() — Change password with code
  • ForgotPasswordView — Complete 2-step screen
  • SignInView integration — «Forgot your password?» link
  • Error handling — 6 test scenarios covered
  • UX best practices — Clear flow, visual feedback

Complete flow: User forgot password → «Forgot your password?» → enters email → forgotPassword() → receives code by email → enters code + new password → confirmForgotPassword() → password updated → signs in with new password.

Final reflection

I invite you to follow me on LinkedIn where I share content related to software development, best practices, and AWS technologies. You can also find me on Medium and explore my projects on GitHub. If you have questions or suggestions, don’t hesitate to connect.

Disclaimer

This article is for educational purposes. In production, you should: implement CAPTCHA or similar to prevent bot abuse, rate limiting on the client side, additional password validation, monitoring of suspicious attempts, secure logging (without sensitive data), and comprehensive testing.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *