Fixing Google SSO UX with AWS Cognito in Flutter: From WebView Hell to Seamless Authentication
For weeks, our team at Whereweup struggled with a frustrating Google SSO integration issue in Leemo, our social network app focused on business discoveries, rewards, and geolocation. Users expecting a smooth "Continue with Google" experience were instead met with password prompts and confusing session behavior. After extensive investigation and testing, we've finally cracked it—and we're sharing our complete solution with you.
The Problem That Drove Us Crazy
Our Flutter app uses AWS Cognito for authentication, with Google as an identity provider. On paper, this should be straightforward. In practice, we encountered two critical UX issues that made our authentication flow feel broken:
- Password prompts instead of account selection: When users tapped "Continue with Google," they saw a browser modal (inside a WebView) asking for their Google password—despite having active Google sessions on their device. The native account chooser never appeared.
- Silent session reuse: After logging out and attempting to log back in, users weren't prompted to select an account. The previous Google session was silently reused, making it impossible to switch accounts or truly log out.
Root Causes
After digging deep into the OAuth flow and Cognito Hosted UI behavior, we identified the culprits:
- In-app WebView limitations: Our OAuth flow ran inside an in-app WebView, which cannot access the device's native account chooser context. The WebView is isolated from system-level authentication sessions, treating every login as if the user had never authenticated before.
- Persistent Cognito cookies: When users logged out, we only cleared local app state. However, Cognito Hosted UI cookies persisted across sessions, enabling silent sign-in on subsequent attempts. The IdP (Google) session was never properly terminated.
Key Insight: WebViews are fundamentally incompatible with modern OAuth best practices for mobile apps. The industry has moved toward using system browsers for OAuth flows, which provide better security, access to native authentication contexts, and compliance with OAuth 2.0 best practices.
The Ideal Solution
After researching OAuth best practices and consulting AWS Cognito documentation, we designed a solution that addresses both issues comprehensively:
- Launch Cognito Hosted UI in external browser: Instead of an in-app WebView, open the OAuth flow in the device's default browser. This gives access to device-level Google sessions and the native account chooser.
- Force account selection: Add
prompt=select_accountto the Hosted UI authorize URL to explicitly request the Google account chooser on every login attempt. - Proper logout flow: Call the Cognito Hosted UI
/logoutendpoint to clear both IdP and Cognito cookies, then wipe local tokens. This ensures a clean slate for the next authentication attempt. - Deep-link callback mechanism: Use a custom URL scheme (e.g.,
leemo://) to return to the app and exchange the authorization code for tokens. - Robust deep-link handling: Implement a reliable deep-link listener using the
app_linkspackage, avoiding any reliance on in-WebView redirects.
Implementation Details
1. Force Account Chooser at Hosted UI
We modified the sign-in URL generation to include the prompt=select_account parameter. This instructs Google's OAuth server to always show the account chooser, even if a session exists.
String getSignInUrl(String provider) {
return 'https://hostedUiDomain/oauth2/authorize?'
'response_type=code&client_id=$appClientId&'
'redirect_uri=${Uri.encodeComponent(redirectUri)}&'
'identity_provider=$provider&'
'scope=aws.cognito.signin.user.admin+email+openid+phone+profile&'
'prompt=select_account';
}
The prompt=select_account parameter is crucial—it overrides any cached authentication state and forces Google to display the account selection interface.
2. Properly Clear Hosted UI Session on Logout
Logout now performs a complete session teardown by hitting the Cognito Hosted UI /logout endpoint in an external browser, then clearing local storage and resetting navigation state.
Future<void> logout() async {
try {
final signOutUrl = getSignOutUrl();
final uri = Uri.parse(signOutUrl);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// Handle launch errors gracefully
} finally {
await ref.read(storageRepositoryProvider).deleteAll();
ref.read(navigationProvider).pushReplacementNamedAndRemoveUntil('/');
}
}
String getSignOutUrl() {
return 'https://hostedUiDomain/logout?'
'client_id=$appClientId&'
'logout_uri=${Uri.encodeComponent(redirectUri)}';
}
The external browser request ensures that Cognito and Google cookies are properly cleared. The finally block guarantees local cleanup happens regardless of network conditions.
3. Remove In-WebView OAuth; Open Hosted UI in External Browser
We completely replaced our WebView login flow with a simple external browser launch. The modal sheet dismisses immediately, and deep-linking completes the authentication flow.
void _initWebView() async {
final String signInUrl =
ref.read(authProvider.notifier).getSignInUrl(widget.provider);
// Create a dummy WebView controller (kept for compatibility)
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.disabled)
..loadHtmlString('<html><body></body></html>');
final uri = Uri.parse(signInUrl);
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} finally {
if (mounted) {
ref.read(navigationProvider).pop();
}
}
}
Note that we use LaunchMode.externalApplication to ensure the URL opens in the system browser, not in an in-app tab or WebView.
4. Robust Deep-Link Handling with app_links
We migrated from uni_links to app_links for more reliable deep-link handling. The implementation listens for incoming URI events and extracts the authorization code to complete the OAuth flow.
Future<void> _startUniLinks() async {
try {
_appLinks = AppLinks();
} on PlatformException catch (_) {
// Handle platform-specific errors
} on FormatException catch (_) {
// Handle URI format errors
}
_sub = _appLinks.uriLinkStream.listen((Uri uri) {
_handleIncomingUri(uri);
}, onError: (_) {
// Handle stream errors
});
}
Future<void> _handleIncomingUri(Uri uri) async {
if (_isHandlingLink) return;
final String redirect = dotenv.env['REDIRECT_URI'] ?? '';
if (redirect.isEmpty) return;
final String uriStr = uri.toString();
if (!uriStr.startsWith(redirect)) return;
final String? code = uri.queryParameters['code'];
if (code == null) return;
_isHandlingLink = true;
try {
final user = await ref
.read(authProvider.notifier)
.exchangeCodeForTokens(code);
if (user != null && mounted) {
ref.read(navigationProvider)
.pushReplacementNamedAndRemoveUntil('/main');
}
} finally {
_isHandlingLink = false;
}
}
The _isHandlingLink flag prevents race conditions if multiple deep-link events fire simultaneously. We validate the URI scheme before processing to avoid handling unrelated deep links.
5. Cognito Hosted UI Configuration
In the AWS Cognito console, we configured the Hosted UI with our custom URL scheme:
- Allowed callback URLs:
leemo://leemo.link - Allowed sign-out URLs:
leemo://leemo.link
These URLs must exactly match the REDIRECT_URI values in your .env.dev and .env.prod files, as well as your Android and iOS app configuration.
6. Build and Plumbing Changes
Several infrastructure changes were necessary to support the new authentication flow:
Package Updates
In pubspec.yaml, we replaced uni_links with app_links for better deep-link support.
Android SDK Requirements
The app_links plugin requires Android SDK 36. We updated our build configuration accordingly:
android {
compileSdkVersion 36
defaultConfig {
targetSdkVersion 36
// ... other config
}
}
Gradle Heap Size
To avoid Jetifier out-of-memory errors during build, we increased the JVM heap size:
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
End-to-End Authentication Flow
Complete OAuth Flow with External Browser and Deep Linking:
- User taps "Sign in with Google"
- External browser opens Cognito Hosted UI
- Google account chooser appears (due to
prompt=select_account) - User selects account and authorizes
- Cognito redirects to
leemo://...with authorization code app_linkscaptures the URI and emits it to our listener- App extracts the
?codeparameter - App calls Cognito's
/oauth2/tokenendpoint to exchange code for tokens - Tokens and user UUID are persisted locally
- User is navigated to the main app screen
- On logout: App opens Hosted UI
/logoutendpoint, clearing all cookies - User returns to landing screen; next login will show account chooser again
Testing Checklist
Before deploying this solution, ensure you test the following scenarios on both Android and iOS:
- Fresh install: Sign in with Google shows account chooser
- Multiple Google accounts: Can select any account from the chooser
- Logout: Properly clears session and returns to landing screen
- Re-login: Account chooser appears again (no silent reuse)
- Deep-link handling: App correctly captures and processes the callback URI
- Network errors: Graceful handling if token exchange fails
- Background/foreground transitions: Deep link still processes correctly
Outcome and Results
After implementing this solution across Android and iOS, we achieved the authentication experience we had always envisioned:
- ✅ Native Google account chooser: Users see a familiar, system-level interface with all their Google accounts—no password prompts
- ✅ Proper session management: Logout correctly terminates all sessions
- ✅ No silent reuse: Every login attempt shows the account chooser, giving users full control
- ✅ Consistent cross-platform behavior: Identical UX on Android and iOS
- ✅ OAuth best practices: Compliant with modern OAuth 2.0 guidelines for native mobile apps
User feedback has been overwhelmingly positive. Support tickets related to login issues have dropped by over 80%, and our authentication completion rate has increased significantly.
Key Takeaways
- Never use in-app WebViews for OAuth: They break the native authentication experience and violate OAuth best practices. Always use the system browser.
- Understand the full session lifecycle: Logout isn't just about clearing local state—you must terminate server-side sessions and IdP cookies.
- The
promptparameter matters: Small OAuth parameters likeprompt=select_accountcan dramatically improve UX. - Deep-linking is tricky but essential: Invest time in robust deep-link handling with proper error cases and race condition prevention.
- Test on real devices: Emulators don't accurately reflect the account chooser behavior, especially on Android.
Resources and Further Reading
- AWS Cognito App Integration Documentation
- OAuth 2.0 for Native Apps (RFC 8252)
- app_links Flutter Package
- Android App Links Documentation
- iOS Universal Links Documentation
Have questions about implementing Google SSO with AWS Cognito in your Flutter app? Reach out to us at Whereweup—we're always happy to share what we've learned. You can also check out Leemo, our social network for business discoveries, rewards, and geolocation.