Automatic Token Refresh with AWS Cognito in iOS

In the previous article, we implemented session persistence, allowing users to not have to log in every time they open the app. However, there’s a critical problem: Access and ID tokens expire after 60 minutes. If the user has the app open for more than an hour, API calls will fail. In this article, we’ll implement automatic token refresh to renew them before they expire.

What are we building?

In this article, we’ll implement:

JWTDecoder – Decode JWT tokens to get expiration date

refreshTokens method – Call Cognito to renew Access/ID tokens using the Refresh Token

TokenRefreshManager – Verify and refresh tokens automatically when needed

ContentView integration – Verify and refresh tokens when opening the app

Error handling – If Refresh Token expired, force re-login

The current problem

Token lifecycle:

Successful login    ↓Access Token → Valid for 60 minutes ⏱️ID Token → Valid for 60 minutes ⏱️Refresh Token → Valid for 30 days 📅    ↓After 60 minutes    ↓Access/ID tokens EXPIRE ❌    ↓API calls fail (401 Unauthorized)    ↓User has to log in again 😞

What we want:

Successful login    ↓Access Token → Valid for 60 minutes    ↓App detects they're about to expire (e.g., 5 minutes left)    ↓App uses Refresh Token automatically    ↓Cognito returns NEW Access/ID tokens    ↓App saves new tokens to Keychain    ↓User continues using the app without interruptions ✅    ↓Repeats every 60 minutes (while Refresh Token is valid - 30 days)

Prerequisites

Have completed Article 5 (Session persistence)

Have KeychainManager implemented

Have CognitoAuthService with signIn and signUp methods

Understand basic JWT (JSON Web Tokens) concepts

What is a JWT?

A JWT (JSON Web Token) has this structure:

eyJhbGc...header.eyJzdWI...payload.SflKxwRJ...signature

It’s divided into 3 parts separated by dots: 1. Header – Information about the signing algorithm 2. Payload – Token data (includes expiration date “exp”) 3. Signature – Signature to verify authenticity

Cognito tokens (Access and ID) are JWTs. The Refresh Token is opaque (not JWT).

Example of decoded payload:

{  "sub": "12345678-1234-1234-1234-123456789abc",  "email": "user@example.com",  "exp": 1701234567,  // ← Expiration date (Unix timestamp)  "iat": 1701230967,  // Issued at  ...}

The exp field tells us when the token expires (seconds since 1970).

Step 1: Create JWTDecoder to decode tokens

First we need to be able to decode tokens to know when they expire.

Create file JWTDecoder.swift:

import Foundation
struct JWTDecoder {    
// MARK: - Decode JWT and get expiration date    
/// Decodes a JWT and returns the expiration date    static func getExpirationDate(from token: String) -> Date? {        // Separate the token into its 3 parts: header.payload.signature        let segments = token.split(separator: ".")        // We need the payload (second part)        guard segments.count == 3 else {            print("❌ Invalid JWT token - doesn't have 3 parts")            return nil        }        let payloadSegment = String(segments[1])        // Decode the payload from Base64        guard let payloadData = base64UrlDecode(payloadSegment) else {            print("❌ Could not decode JWT payload")            return nil        }        // Parse the payload JSON        guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],              let exp = json["exp"] as? TimeInterval else {            print("❌ 'exp' field not found in payload")            return nil        }        // Convert Unix timestamp to Date        let expirationDate = Date(timeIntervalSince1970: exp)        print("✅ Token expires at: \(expirationDate)")        return expirationDate    }    
/// Checks if a token is expired    static func isTokenExpired(_ token: String) -> Bool {        guard let expirationDate = getExpirationDate(from: token) else {            return true  // If we can't decode, assume it's expired        }        return Date() >= expirationDate    }    
/// Checks if a token will expire soon (within N minutes)    static func willExpireSoon(_ token: String, minutesThreshold: Int = 5) -> Bool {        guard let expirationDate = getExpirationDate(from: token) else {            return true  // If we can't decode, assume it needs refresh        }        // Calculate threshold date (now + threshold)        let thresholdDate = Date().addingTimeInterval(TimeInterval(minutesThreshold * 60))        // If expiration is before threshold, we need refresh        return expirationDate <= thresholdDate    }    
/// Gets the remaining time until token expiration    static func getTimeUntilExpiration(_ token: String) -> TimeInterval? {        guard let expirationDate = getExpirationDate(from: token) else {            return nil        }        let timeRemaining = expirationDate.timeIntervalSinceNow        return max(0, timeRemaining) // Don't return negative values    }    
// MARK: - Base64 URL Decoding    
/// Decodes a Base64 URL-safe string    
/// JWT uses Base64 URL-safe which is different from standard Base64    private static func base64UrlDecode(_ value: String) -> Data? {        var base64 = value            .replacingOccurrences(of: "-", with: "+")            .replacingOccurrences(of: "_", with: "/")        // Add padding if necessary        let remainder = base64.count % 4        if remainder > 0 {            base64 += String(repeating: "=", count: 4 - remainder)        }        return Data(base64Encoded: base64)    }}

What does this code do?

getExpirationDate():

Splits JWT into 3 parts (header.payload.signature)

Decodes the payload from Base64 URL-safe

Extracts the exp field (expiration)

Converts Unix timestamp to Date

isTokenExpired():

Checks if the token has already expired by comparing with Date()

Returns true if expired

willExpireSoon():

Checks if the token expires in less than N minutes (default: 5)

Useful for refreshing BEFORE it expires

getTimeUntilExpiration():

Calculates how many seconds remain until expiration

Useful for displaying to user or for logging

base64UrlDecode():

JWT uses Base64 URL-safe (different from standard Base64)

Replaces – with + and _ with /

Adds padding = if necessary

Step 2: Add refreshTokens method in CognitoAuthService

Now we add refresh functionality to the authentication service.

Add in CognitoAuthService.swift (after the signIn method):

// MARK: - Token Refresh
/// Renews Access and ID tokens using the Refresh Tokenfunc refreshTokens(refreshToken: String) async throws -> AuthTokens {    guard let client = cognitoClient else {        throw NSError(domain: "CognitoAuthService", code: -1,                     userInfo: [NSLocalizedDescriptionKey: "Cognito client not initialized"])    }    // Create authentication parameters with the Refresh Token    let authParameters = [        "REFRESH_TOKEN": refreshToken    ]    // Create refresh request using REFRESH_TOKEN_AUTH flow    let initiateAuthInput = InitiateAuthInput(        authFlow: .refreshTokenAuth,        authParameters: authParameters,        clientId: clientId    )    do {        let response = try await client.initiateAuth(input: initiateAuthInput)        print("✅ Tokens refreshed successfully")        // Extract new tokens from response        guard let authResult = response.authenticationResult else {            throw NSError(domain: "CognitoAuthService", code: 5001,                         userInfo: [NSLocalizedDescriptionKey: "Could not obtain new tokens"])        }        // IMPORTANT: Refresh does NOT return a new Refresh Token        // We must keep the original Refresh Token        let tokens = AuthTokens(            accessToken: authResult.accessToken ?? "",            idToken: authResult.idToken ?? "",            refreshToken: refreshToken  // ← Keep the same Refresh Token        )        print("New tokens obtained:")        print("Access Token: \(tokens.accessToken.prefix(20))...")        print("ID Token: \(tokens.idToken.prefix(20))...")        print("Refresh Token: (unchanged)")        return tokens    } catch {        print("❌ Error refreshing tokens: \(error)")        let errorMessage = error.localizedDescription        if errorMessage.contains("NotAuthorizedException") {            throw NSError(domain: "CognitoAuthService", code: 5002,                        userInfo: [NSLocalizedDescriptionKey: "Refresh Token invalid or expired. You must sign in again"])        } else {            throw NSError(domain: "CognitoAuthService", code: 5000,                        userInfo: [NSLocalizedDescriptionKey: "Error refreshing tokens: \(errorMessage)"])        }    }}

What does this method do?

Different AuthFlow:

Uses .refreshTokenAuth instead of .userPasswordAuth

Only needs the Refresh Token (not username/password)

Parameters:

"REFRESH_TOKEN": refreshToken

Only send the Refresh Token

IMPORTANT about Refresh Token:

AWS Cognito does NOT return a new Refresh Token when refreshing

Only returns new Access and ID tokens

We must keep the original Refresh Token

Refresh Token only renews on a complete login

Error handling:

NotAuthorizedException → Refresh Token expired or invalid (code 5002)

Other errors → Generic error (code 5000)

If Refresh Token expired, user MUST log in again

Step 3: Create TokenRefreshManager

We create a manager that coordinates the entire verification and automatic refresh process.

Create file TokenRefreshManager.swift:

import Foundation
class TokenRefreshManager {    static let shared = TokenRefreshManager()    private let authService = CognitoAuthService()    private let keychainManager = KeychainManager.shared    // Threshold in minutes: if token expires in less than this time, refresh it    private let expirationThresholdMinutes = 5    private init() {}    
// MARK: - Check and Refresh Tokens    
/// Checks if tokens need to be refreshed and renews them automatically    
/// - Returns: true if tokens are valid (or were refreshed successfully), false if re-login needed    @discardableResult    func checkAndRefreshTokensIfNeeded() async -> Bool {        print("🔍 Checking token status...")        // 1. Read tokens from Keychain        guard let tokens = keychainManager.readTokens() else {            print("⚠️ No saved tokens")            return false        }        // 2. Check if Access Token needs refresh        let needsRefresh = JWTDecoder.willExpireSoon(            tokens.accessToken,            minutesThreshold: expirationThresholdMinutes        )        if !needsRefresh {            // Tokens are still valid            if let timeRemaining = JWTDecoder.getTimeUntilExpiration(tokens.accessToken) {                let minutes = Int(timeRemaining / 60)                print("✅ Tokens valid. Expire in ~\(minutes) minutes")            }            return true        }        // 3. Tokens need refresh        print("⏰ Tokens about to expire. Starting refresh...")        do {            // 4. Call service to refresh            let newTokens = try await authService.refreshTokens(refreshToken: tokens.refreshToken)            // 5. Save new tokens to Keychain            let saved = keychainManager.saveTokens(newTokens)            if saved {                print("✅ Tokens refreshed and saved successfully")                return true            } else {                print("❌ Error saving new tokens")                return false            }        } catch {            print("❌ Error refreshing tokens: \(error.localizedDescription)")            // If Refresh Token expired, we must force re-login            if (error as NSError).code == 5002 {                print("🔴 Refresh Token expired. User must sign in again")                // Clear invalid tokens                _ = keychainManager.deleteTokens()            }            return false        }    }    
// MARK: - Helper methods    func hasValidTokens() -> Bool {        guard let tokens = keychainManager.readTokens() else {            return false        }        return !JWTDecoder.isTokenExpired(tokens.accessToken)    }    func getTimeUntilTokenExpiration() -> TimeInterval? {        guard let tokens = keychainManager.readTokens() else {            return nil        }        return JWTDecoder.getTimeUntilExpiration(tokens.accessToken)    }}

What does TokenRefreshManager do?

Singleton pattern:

static let shared = TokenRefreshManager()

Single shared instance throughout the app

checkAndRefreshTokensIfNeeded() – The main method:

Step 1: Read tokens from Keychain

Step 2: Check if they expire soon (< 5 minutes)

Step 3: If OK → return true

Step 4: If expiring soon → call refreshTokens()

Step 5: Save new tokens to Keychain

Step 6: If refresh fails → clear tokens and return false

5-minute threshold:

Don’t wait until they completely expire

Refresh when less than 5 minutes remain

This provides margin to avoid API errors

Error handling:

If Refresh Token expired (code 5002) → delete tokens and force re-login

User will see SignInView automatically

Step 4: Integrate automatic refresh in ContentView

Finally, we update ContentView to use TokenRefreshManager instead of just checking if tokens exist.

Update checkSession() method in ContentView.swift:

// MARK: - Check Sessionprivate func checkSession() {    // Check and refresh tokens if needed    Task {        // TokenRefreshManager checks if tokens exist, if they're valid,        // and renews them automatically if they're about to expire        let hasValidSession = await TokenRefreshManager.shared.checkAndRefreshTokensIfNeeded()        await MainActor.run {            isLoggedIn = hasValidSession            isCheckingSession = false            if hasValidSession {                print("✅ Valid session - Navigating to HomeView")            } else {                print("⚠️ No valid session - Showing SignInView")            }        }    }}

What changed?

Before (Article 5):

let hasTokens = KeychainManager.shared.hasTokens()// Only checked if there were tokens, not if they were valid

Now (Article 6):

let hasValidSession = await TokenRefreshManager.shared.checkAndRefreshTokensIfNeeded()// Checks if there are tokens, if they're valid, and renews them automatically
Complete flow: 1. User opens app 2. ContentView.onAppear() executes checkSession() 3. checkSession() calls TokenRefreshManager 4. TokenRefreshManager checks tokens: - Are there tokens? NO → return false → SignInView - Expire soon? NO → return true → HomeView - Expire soon? YES → automatic refresh → return true → HomeView - Refresh failed? YES → return false → SignInView

Step 5: Test automatic refresh flow

Now let’s test that refresh works correctly:

Test 1: Session with valid tokens (NO refresh)

Log in

Enter email and password

Press “Sign In”

Console:

✅ Tokens saved to Keychain

Close and reopen the app immediately

Tokens were created < 5 minutes ago

Console:

🔍 Checking token status...✅ Tokens valid. Expire in ~55 minutes✅ Valid session - Navigating to HomeView

✅ NO refresh because tokens are still valid

Test 2: Force refresh (simulated)

To test refresh we need tokens that are about to expire. There are two ways:

Option A: Wait 55 minutes (not practical)

Option B: Temporarily modify the threshold (for testing)

Temporarily modify TokenRefreshManager.swift:

// TEMPORARY ONLY FOR TESTINGprivate let expirationThresholdMinutes = 60  // Was 5, now 60

This will make it ALWAYS try to refresh because tokens will always be “about to expire” (< 60 min).

Rebuild and reopen the app

Console:

🔍 Checking token status...⏰ Tokens about to expire. Starting refresh...✅ Tokens refreshed successfully✅ Tokens refreshed and saved successfully✅ Valid session - Navigating to HomeView
✅ Tokens were refreshed automatically

✅ User did NOT have to log in again

Restore the threshold:

private let expirationThresholdMinutes = 5 // Back to original value

Test 3: Expired Refresh Token (force re-login)

To test this we need an expired Refresh Token. This happens after 30 days or if you change the configuration in Cognito.

Simulate expired Refresh Token:

Temporarily modify KeychainManager (corrupt Refresh Token):

In signIn() of SignInView, after saving tokens:

// TEMPORARY - Corrupt Refresh Token to simulate expirationlet corruptedTokens = AuthTokens(    accessToken: tokens.accessToken,    idToken: tokens.idToken,    refreshToken: "expired_invalid_token")let saved = KeychainManager.shared.saveTokens(corruptedTokens)

Log in and close the app

Reopen the app

Console:

🔍 Checking token status...⏰ Tokens about to expire. Starting refresh...❌ Error refreshing tokens: Refresh Token invalid or expired🔴 Refresh Token expired. User must sign in again⚠️ No valid session - Showing SignInView
✅ Tokens were deleted

✅ User sees SignInView (must log in again)

Remove temporary code

When is refresh executed?

Automatic refresh executes:

When opening the app – ContentView.onAppear() → checkSession()

When user returns from background (we’ll implement this in the next article)

Before making API calls (we’ll implement this when integrating APIs)

Token lifecycle

Successful login (Day 1)    ↓Access Token valid: 60 minutesID Token valid: 60 minutesRefresh Token valid: 30 days    ↓Minute 55 (first time opening app after 55 min)    ↓TokenRefreshManager detects: < 5 minutes left    ↓Automatic refresh → New Access/ID tokens    ↓Each time app opens in the next 30 days:    ↓If tokens expire soon → automatic refresh    ↓Day 31 (Refresh Token expired)    ↓Refresh fails → Tokens deleted → SignInView → Re-login

Conclusion

In this article, we implemented automatic token refresh for our AWS Cognito app. Now users can use the app for 30 days without having to log in again, as long as they open the app at least once every 60 minutes (or automatic refresh handles it when they return).

What we achieved:

  • ✅ JWTDecoder – Decode tokens to get expiration date
  • ✅ refreshTokens() – Renew Access/ID tokens using Refresh Token
  • ✅ TokenRefreshManager – Verify and refresh tokens automatically
  • ✅ ContentView integration – Refresh when opening the app
  • ✅ Error handling – If Refresh Token expired, force re-login
Complete flow:
User opens app    → ContentView.checkSession()    → TokenRefreshManager.checkAndRefreshTokensIfNeeded()    → Are there saved tokens?        NO → return false → SignInView        YES → Tokens valid (> 5 min)?            YES → return true → HomeView            NO → Refresh successful?                YES → return true → HomeView                NO → Clear tokens → return false → SignInView

What’s next?

In the next article, we’ll implement: – Refresh when returning from background – If user minimizes app for 1 hour – Interceptor for API calls – Refresh tokens before each API call – Expiration notifications – Notify user before session expires

Final reflection

Automatic token refresh is CRITICAL for good user experience. Without it: users would have to log in every 60 minutes, API calls would fail after 1 hour, and the app would seem unstable. With automatic refresh, users log in ONCE every 30 days, and the session is transparent and smooth.

With automatic refresh: – User logs in ONCE every 30 days – Session is transparent and smooth – Tokens are always up to date

Best practices: 1. 5-minute threshold – Refresh BEFORE they expire (provides error margin) 2. Clear expired tokens – If Refresh Token failed, delete everything 3. Clear logging – Helps debugging (see when and why they refresh) 4. Robust error handling – Differentiate between “token expired” vs “network error”

José Luján is a mobile developer specialized in iOS, Android, and cross-platform development (Flutter), expert in integrating AWS services in mobile apps, and part of the Amazon Community Builders program. Follow me on LinkedIn, read more articles on Medium, and explore my projects on GitHub.

About validations

Disclaimer: This article is for educational purposes. In production, you should: – Implement refresh also when app returns from background (NotificationCenter with UIApplication.willEnterForegroundNotification) – Add network interceptor to refresh tokens before EVERY API call – Consider using libraries like Amplify that handle this automatically – Implement retry logic if refresh fails due to temporary network error – Add analytics to monitor refresh frequency and errors – Consider security policies: allow indefinite refresh or force re-login every X days? – In some cases, validate the token on the backend in addition to just checking expiration

Deja una respuesta

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