Sign In with AWS Cognito on iOS (without Amplify)

In the previous articles, we implemented user registration and account confirmation. Now we have registered and confirmed users in AWS Cognito, but they still can’t access the application. In this article, we’ll implement sign-in so users can authenticate and obtain their access tokens.

What are we building?

A sign-in screen that will be the main screen of the application, where the user can:

  • Sign in with email and password
  • Navigate to the registration screen if they don’t have an account
  • Obtain authentication tokens from AWS Cognito (Access Token, ID Token, Refresh Token)
  • Handle common errors (incorrect credentials, unconfirmed user, etc.)

Important Change in Navigation Flow

Until now, our app started directly on the registration screen (RegisterView). This makes sense for learning, but in a real application:

  • The main screen is sign-in (SignInView)
  • From there the user can go to register if they don’t have an account
  • After registering and confirming, the user returns to login to sign in

The new flow will be:

App starts → SignInView (main screen)
              ↓
              ├─→ "Sign In" → Welcome screen
              │
              └─→ "Don't have an account? Sign Up" → RegisterView
                                                      ↓
                                                   ConfirmCodeView
                                                      ↓
                                                   Returns to SignInView

Prerequisites

  • Have completed the previous articles (Registration and Confirmation)
  • Have at least one CONFIRMED user in AWS Cognito
  • Know the credentials (email and password) of a confirmed user

Step 1: Creating the Sign-In Screen

We’ll create SignInView.swift which will be the new main screen of our app. In Xcode: right-click on the project folder → New File → SwiftUI View → Name: SignInView.

import SwiftUI
struct SignInView: View {
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var isLoading: Bool = false
    @State private var message: String = ""
    @State private var showAlert: Bool = false
    @State private var navigateToRegister: Bool = false
    var body: some View {
        VStack(spacing: 20) {
            // Logo or title
            Text("Welcome")
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding(.bottom, 10)
            Text("Sign in to your account")
                .font(.subheadline)
                .foregroundColor(.gray)
                .padding(.bottom, 30)
            // Email Field
            TextField("Email", text: $email)
                .textInputAutocapitalization(.never)
                .keyboardType(.emailAddress)
                .padding()
                .background(Color(.systemGray6))
                .cornerRadius(10)
                .disabled(isLoading)
            // Password Field
            SecureField("Password", text: $password)
                .padding()
                .background(Color(.systemGray6))
                .cornerRadius(10)
                .disabled(isLoading)
            // Status message
            if !message.isEmpty {
                Text(message)
                    .font(.caption)
                    .foregroundColor(.gray)
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
            }
            // Sign In Button
            Button(action: {
                signIn()
            }) {
                if isLoading {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle(tint: .white))
                        .frame(maxWidth: .infinity)
                        .padding()
                } else {
                    Text("Sign In")
                        .fontWeight(.semibold)
                        .foregroundColor(.white)
                        .frame(maxWidth: .infinity)
                        .padding()
                }
            }
            .background(isLoading ? Color.gray : Color.blue)
            .cornerRadius(10)
            .disabled(isLoading)
            .padding(.top, 20)
            // Link to Registration
            HStack {
                Text("Don't have an account?")
                    .foregroundColor(.gray)
                Button(action: {
                    navigateToRegister = true
                }) {
                    Text("Sign Up")
                        .fontWeight(.semibold)
                        .foregroundColor(.blue)
                }
            }
            .padding(.top, 10)
            Spacer()
        }
        .padding(.horizontal, 30)
        .padding(.top, 80)
        .alert("Sign In", isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        } message: {
            Text(message)
        }
        .navigationDestination(isPresented: $navigateToRegister) {
            RegisterView()
        }
    }
    // MARK: - Sign In Function
    private func signIn() {
        guard !email.isEmpty, !password.isEmpty else {
            message = "Please fill in all fields"
            showAlert = true
            return
        }
        isLoading = true
        message = "Signing in..."
        // Simulation — will be replaced with AWS Cognito in Step 5
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            isLoading = false
            message = "Sign in successful"
            showAlert = true
        }
    }
}

What does this code do?

Local states: email and password store user credentials; isLoading controls the spinner; navigateToRegister triggers navigation to registration.

Key differences from RegisterView: Only asks for email and password (no confirmation field), has a «Sign Up» link instead of a «Sign In» one, and .navigationDestination navigates forward to RegisterView.

Step 2: Updating App Navigation

2.1 Update ContentView

Change ContentView.swift to start on SignInView:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            SignInView()  // Changed from RegisterView() to SignInView()
        }
    }
}

2.2 Update RegisterView for Bidirectional Navigation

Add @Environment(\.dismiss) to allow returning to the login screen:

struct RegisterView: View {
    @Environment(\.dismiss) private var dismiss  // New
    @State private var email: String = ""
    @State private var password: String = ""
    // ... rest of the states

After the «Sign Up» button, add the link back to login:

            .padding(.top, 20)
            // Link to return to Login
            HStack {
                Text("Already have an account?")
                    .foregroundColor(.gray)
                Button(action: {
                    dismiss()
                }) {
                    Text("Sign In")
                        .fontWeight(.semibold)
                        .foregroundColor(.blue)
                }
            }
            .padding(.top, 10)
            Spacer()

dismiss() closes RegisterView and returns to SignInView, maintaining a clean navigation stack.

2.3 Complete Navigation Flow

App starts
    ↓
ContentView (NavigationStack)
    ↓
SignInView (main screen)
    ↓
    ├─→ Click "Sign Up" → RegisterView
    │                         ↓
    │                         ├─→ Click "Sign In" → dismiss() → SignInView
    │                         ↓
    │                      Registration OK → ConfirmCodeView
    │                                          ↓
    │                                       Confirmation OK
    │
    └─→ Click "Sign In" → (next step: home screen)

Step 3: Configure Authentication Flow in AWS Cognito

Before implementing the code, we need to configure the App Client in AWS Cognito to allow the authentication flow we’re going to use. Without this step, the code will error.

3.1 Why do we need this configuration?

AWS Cognito supports several authentication flows, all disabled by default:

  • USER_PASSWORD_AUTH: User sends email and password directly to Cognito
  • USER_SRP_AUTH: Uses SRP (Secure Remote Password) — more secure, password never leaves device
  • CUSTOM_AUTH: Custom authentication (SMS, biometrics, etc.)
  • REFRESH_TOKEN_AUTH: Obtain new tokens using the refresh token

3.2 Enable USER_PASSWORD_AUTH Flow

  1. Go to AWS Console → Cognito → User pools and open your User Pool.
  2. Click the «App integration» tab, scroll to «App clients and analytics».
  3. Click your App Client name, then click «Edit».
  4. In the «Authentication flows» section, check: ✅ ALLOW_USER_PASSWORD_AUTH and ✅ ALLOW_REFRESH_TOKEN_AUTH.
  5. Click «Save changes».

3.3 Understanding Token Configuration

On the same screen you’ll find the token lifetime settings. The defaults are:

TokenDefaultPurpose
Authentication session3 minutesMax time to complete an auth flow (e.g., MFA)
Refresh token30 daysHow long the refresh token is valid
Access token60 minutesHow long the access token is valid
ID token60 minutesHow long the ID token is valid

For production, consider adjusting based on your security requirements:

ScenarioRefresh TokenAccess Token
Banking app (high security)7 days5 minutes
Corporate app30 days15 minutes
Social media app90 days60 minutes
Gaming app180 days120 minutes

Important: If you change these values after users have already signed in, existing tokens keep their original expiration time. New values only apply to tokens generated after the change.

Step 4: Implementing the signIn() Method in CognitoAuthService

4.1 Create the Tokens Model

At the end of CognitoAuthService.swift (outside the class), add:

// MARK: - Tokens Model
struct AuthTokens {
    let accessToken: String
    let idToken: String
    let refreshToken: String
}

4.2 Implement the signIn() Method

// MARK: - Sign In
func signIn(email: String, password: String) async throws -> AuthTokens {
    guard let client = cognitoClient else {
        throw NSError(domain: "CognitoAuthService", code: -1,
                     userInfo: [NSLocalizedDescriptionKey: "Cognito client not initialized"])
    }
    let authParameters = [
        "USERNAME": email,
        "PASSWORD": password
    ]
    let initiateAuthInput = InitiateAuthInput(
        authFlow: .userPasswordAuth,
        authParameters: authParameters,
        clientId: clientId
    )
    do {
        let response = try await client.initiateAuth(input: initiateAuthInput)
        print("✅ Sign in successful")
        guard let authResult = response.authenticationResult else {
            throw NSError(domain: "CognitoAuthService", code: 4001,
                         userInfo: [NSLocalizedDescriptionKey: "Could not obtain tokens"])
        }
        let tokens = AuthTokens(
            accessToken: authResult.accessToken ?? "",
            idToken: authResult.idToken ?? "",
            refreshToken: authResult.refreshToken ?? ""
        )
        print("Access Token: \(tokens.accessToken.prefix(20))...")
        print("ID Token: \(tokens.idToken.prefix(20))...")
        return tokens
    } catch {
        print("❌ Error signing in: \(error)")
        let errorMessage = error.localizedDescription
        if errorMessage.contains("NotAuthorizedException") {
            throw NSError(domain: "CognitoAuthService", code: 4002,
                        userInfo: [NSLocalizedDescriptionKey: "Incorrect email or password"])
        } else if errorMessage.contains("UserNotConfirmedException") {
            throw NSError(domain: "CognitoAuthService", code: 4003,
                        userInfo: [NSLocalizedDescriptionKey: "User not confirmed. Check your email"])
        } else if errorMessage.contains("UserNotFoundException") {
            throw NSError(domain: "CognitoAuthService", code: 4004,
                        userInfo: [NSLocalizedDescriptionKey: "User does not exist"])
        } else {
            throw NSError(domain: "CognitoAuthService", code: 4000,
                        userInfo: [NSLocalizedDescriptionKey: "Error signing in: \(errorMessage)"])
        }
    }
}

This method uses the USER_PASSWORD_AUTH flow, extracts the three tokens from authenticationResult, and handles specific error types: incorrect credentials, unconfirmed user, and non-existent user.

Step 5: Connecting SignInView with AWS Cognito

5.1 Add States for Tokens

struct SignInView: View {
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var isLoading: Bool = false
    @State private var message: String = ""
    @State private var showAlert: Bool = false
    @State private var navigateToRegister: Bool = false
    @State private var authTokens: AuthTokens? = nil  // New
    private let authService = CognitoAuthService()    // New

5.2 Display Tokens in the UI (Educational)

After the status message and before the «Sign In» button, add a section to display the received tokens:

// Show tokens if they exist (educational only — never in production)
if let tokens = authTokens {
    VStack(alignment: .leading, spacing: 10) {
        Text("✅ Tokens received:")
            .font(.headline)
            .foregroundColor(.green)
        VStack(alignment: .leading, spacing: 5) {
            Text("Access Token:")
                .font(.caption).foregroundColor(.gray)
            Text(tokens.accessToken.prefix(60) + "...")
                .font(.system(size: 10, design: .monospaced))
                .foregroundColor(.blue)
            Text("ID Token:")
                .font(.caption).foregroundColor(.gray).padding(.top, 5)
            Text(tokens.idToken.prefix(60) + "...")
                .font(.system(size: 10, design: .monospaced))
                .foregroundColor(.blue)
            Text("Refresh Token:")
                .font(.caption).foregroundColor(.gray).padding(.top, 5)
            Text(tokens.refreshToken.prefix(60) + "...")
                .font(.system(size: 10, design: .monospaced))
                .foregroundColor(.blue)
        }
    }
    .padding()
    .background(Color(.systemGray6))
    .cornerRadius(10)
    .padding(.top, 10)
}

5.3 Update the signIn() Function

// MARK: - Sign In Function
private func signIn() {
    guard !email.isEmpty, !password.isEmpty else {
        message = "Please fill in all fields"
        showAlert = true
        return
    }
    isLoading = true
    message = "Signing in..."
    authTokens = nil  // Clear previous tokens
    Task {
        do {
            let tokens = try await authService.signIn(email: email, password: password)
            await MainActor.run {
                isLoading = false
                authTokens = tokens
                message = "✅ Sign in successful"
            }
        } catch {
            await MainActor.run {
                isLoading = false
                authTokens = nil
                message = error.localizedDescription
                showAlert = true
            }
        }
    }
}

Step 6: Testing the Complete Flow

6.1 Test Successful Sign-In

  1. Enter the email of your confirmed user
  2. Enter the correct password
  3. Tap «Sign In»
  4. Expected: Loading spinner → «✅ Sign in successful» → Tokens shown on screen

6.2 Test Common Errors

  • Incorrect password: «Incorrect email or password»
  • User doesn’t exist: «User does not exist»
  • User not confirmed: «User not confirmed. Check your email»

6.3 Verify in Xcode Console

✅ Sign in successful
Access Token: eyJraWQiOiJyRzBVd...
ID Token: eyJraWQiOiJyRzBVd...

These logs confirm: connection with AWS Cognito was successful, valid tokens were received, and the tokens are JWTs (start with eyJ).

Sign in screen showing received tokens

Understanding the Tokens You Received

Access Token

A temporary credential that authorizes your app to access protected resources. It’s a JWT (decode it at jwt.io) that expires after 60 minutes. Sent in every HTTP request as a Bearer token:

var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

It does not contain personal user information — only scopes, username, and expiration.

ID Token

Contains user information (claims). Decode it and you’ll see:

{
  "sub": "1234-5678-90ab-cdef",
  "email": "jose@example.com",
  "email_verified": true,
  "cognito:username": "jose",
  "auth_time": 1234567890,
  "exp": 1234567890,
  "iat": 1234567890
}

Used inside the app to display user info. Do NOT send it to APIs — only the Access Token goes there.

Refresh Token

A long-lived token (30 days) used to obtain new Access and ID tokens without asking for credentials again:

1. Successful login → Receive: Access Token + ID Token + Refresh Token
2. Use Access Token in API requests → Works for 60 minutes
3. Access Token expires → App detects 401 error
4. App uses Refresh Token → Calls Cognito: "Give me new tokens"
5. Cognito returns new Access Token + ID Token
6. App continues → User does NOT need to login again
   [Cycle repeats every 60 minutes for 30 days]
Day 30 → Refresh Token expires → User MUST login again

The Refresh Token is the most sensitive of the three. Store it in Keychain, never in UserDefaults. When the user signs out, delete it immediately.

Token Usage Summary

TokenDurationWhere used?Purpose
Access Token60 minutesHTTP requests to APIsAuthorize access to resources
ID Token60 minutesInside the appGet user info (email, name)
Refresh Token30 daysRequests to CognitoRenew the other two tokens

Conclusion

In this article we implemented complete sign-in with AWS Cognito:

  • SignInView: Main app screen with functional login
  • Updated navigation: App starts on login (not registration)
  • AWS configuration: Enabled USER_PASSWORD_AUTH flow
  • signIn() method: Real authentication using InitiateAuth
  • Token reception: Access, ID and Refresh tokens working
  • Educational visualization: Tokens shown on screen to understand how they work
  • Error handling: Specific messages for each error type

Now users can register (Article 1), confirm their account (Article 2), and sign in (Article 3). In the next article we’ll learn to manage these tokens securely, implement automatic refresh, and sign out correctly.

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 — I’m always open to exchanging ideas and learning alongside the community.

About Validations

This article series focuses on understanding how AWS Cognito works in its purest form. In a real production project, you should consider:

  • Store tokens in Keychain (never in UserDefaults or unencrypted files)
  • Never show tokens in the UI
  • Implement automatic token refresh
  • Clear tokens on sign out and validate expiration before using them
  • Use SRP_AUTH instead of USER_PASSWORD_AUTH (more secure)
  • Implement MFA (multi-factor authentication)
  • Rate limiting to prevent brute force attacks
  • Robust password requirements (uppercase, numbers, symbols)
  • Authentication event logs and monitoring
  • Unit and integration testing

The goal is for you to first understand how Cognito works, and then apply development best practices according to your project’s needs.

Deja una respuesta

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