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