Flutter
Add Base IdP login to a Flutter app — public client, PKCE, no secret on device. Full platform setup for iOS and Android.
Flutter is a public client. The app opens a system browser, runs PKCE, and receives an authorization code on a custom URL scheme. The client id ships in the binary; no secret ever does. After the SDK exchanges the code for tokens, the typical pattern is to send the session to your backend so it can issue its own session tokens for your product API.
This guide covers the full integration: scaffolding, the Dart code, the iOS and Android platform setup, and sending the session to a backend.
Scaffold
npx base-idp create \
--stack flutter \
--client-id sq_live_yourapp \
--redirect-uri "myapp://auth/callback"This writes a lib/auth/base_idp_auth.dart, a run.sh, and a setup readme
into ./base-idp-flutter/. Copy the Dart file into your project, then
follow the platform steps below.
Install
flutter pub add base_idpThe package brings flutter_web_auth_2 along with it for the in-app browser
session.
The Dart side
import 'package:base_idp/base_idp.dart';
class AppAuth {
AppAuth()
: _auth = BaseIdpFlutterAuth(
config: const BaseIdpConfig(
clientId: String.fromEnvironment('BASE_IDP_CLIENT_ID'),
),
redirectUri: 'myapp://auth/callback',
);
final BaseIdpFlutterAuth _auth;
Future<BaseIdpMobileSession> signIn() => _auth.login();
}The client id is read from --dart-define at build time. That keeps it out
of source control without using a .env file at all.
Running with the client id
flutter run --dart-define=BASE_IDP_CLIENT_ID=sq_live_yourappFor convenience, the scaffolder writes this into run.sh so you do not
have to remember it every time.
#!/usr/bin/env bash
flutter run --dart-define=BASE_IDP_CLIENT_ID=sq_live_yourappiOS setup
The redirect URI uses a custom URL scheme. iOS needs to know your app handles it.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>The CFBundleURLName is informational. The string inside CFBundleURLSchemes
is what actually matters — it has to equal the scheme part of your
redirect_uri (before the ://).
iOS minimum versions
flutter_web_auth_2 requires iOS 13+. Update your Podfile platform line if
you are still on the default 12:
platform :ios, '13.0'Android setup
Android needs an intent filter on your main activity so the OS knows the scheme returns to your app.
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
... >
<!-- Existing intent-filter for android.intent.action.MAIN -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>The android:scheme value must match the scheme in your redirect URI.
Calling sign-in from the UI
import 'package:flutter/material.dart';
import 'package:base_idp/base_idp.dart';
import '../auth/app_auth.dart';
class SignInScreen extends StatefulWidget {
const SignInScreen({super.key});
@override
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends State<SignInScreen> {
final _auth = AppAuth();
bool _busy = false;
String? _error;
Future<void> _signIn() async {
setState(() {
_busy = true;
_error = null;
});
try {
final session = await _auth.signIn();
if (!mounted) return;
Navigator.of(context).pushReplacementNamed('/home', arguments: session);
} on BaseIdpException catch (e) {
setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_error != null) Text(_error!),
FilledButton(
onPressed: _busy ? null : _signIn,
child: Text(_busy ? 'Signing in...' : 'Sign in'),
),
],
),
),
);
}
}Sending the session to your backend
The most common pattern: the app signs in with Base IdP, then POSTs the session to your backend's exchange endpoint, which issues your own session.
import 'package:dio/dio.dart';
import 'package:base_idp/base_idp.dart';
class AuthRepository {
AuthRepository(this._dio);
final Dio _dio;
Future<MySession> exchangeWithBackend(BaseIdpMobileSession session) async {
final response = await _dio.post(
'/auth/idp-exchange',
data: session.toServerPayload(),
);
return MySession.fromJson(response.data);
}
}On the server side, see Go, Rust, or Express for the verify-only receiving pattern.
Storing tokens securely
Tokens belong in secure storage, not in shared preferences. Use
flutter_secure_storage.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureTokenStore {
static const _storage = FlutterSecureStorage();
Future<void> save(String accessToken, String refreshToken) async {
await _storage.write(key: 'access_token', value: accessToken);
await _storage.write(key: 'refresh_token', value: refreshToken);
}
Future<String?> readAccessToken() => _storage.read(key: 'access_token');
Future<String?> readRefreshToken() => _storage.read(key: 'refresh_token');
Future<void> clear() => _storage.deleteAll();
}On iOS this uses the Keychain. On Android it uses EncryptedSharedPreferences.
Refresh and logout
Future<void> refresh() async {
final refreshToken = await _store.readRefreshToken();
if (refreshToken == null) return;
final tokens = await _auth.refresh(refreshToken);
await _store.save(tokens.accessToken, tokens.refreshToken);
}
Future<void> signOut() async {
final refreshToken = await _store.readRefreshToken();
if (refreshToken != null) {
await _auth.logout(refreshToken);
}
await _store.clear();
}Things that commonly go wrong
The browser opens and never returns
The most common bug. The redirect URI in your code, the scheme registered on the OS, and the redirect URI registered on the Base IdP app must all match exactly.
Check, in order:
- The
redirectUriin yourBaseIdpFlutterAuthconstructor. - The
CFBundleURLSchemesarray inInfo.plist(iOS). - The
android:schemeattribute inAndroidManifest.xml(Android). - The "Allowed redirect URIs" list on your app's Base IdP registration.
invalid_client
Your client id does not match a registration. Run
npx base-idp test --client-id sq_live_yourapp to confirm.
invalid_redirect_uri
The redirect URI you used in the authorize request is not in the allowed list on your registration. Add it in Square Experience Cloud.
Tokens lost between launches
You forgot to use secure storage. Add flutter_secure_storage and persist
the tokens.