Base IdP

Base IdP

Frameworks

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_idp

The package brings flutter_web_auth_2 along with it for the in-app browser session.

The Dart side

lib/auth/app_auth.dart
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_yourapp

For convenience, the scaffolder writes this into run.sh so you do not have to remember it every time.

run.sh
#!/usr/bin/env bash
flutter run --dart-define=BASE_IDP_CLIENT_ID=sq_live_yourapp

iOS setup

The redirect URI uses a custom URL scheme. iOS needs to know your app handles it.

ios/Runner/Info.plist
<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:

ios/Podfile
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.

android/app/src/main/AndroidManifest.xml
<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

lib/screens/sign_in_screen.dart
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.

lib/auth/auth_repository.dart
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.

lib/auth/secure_token_store.dart
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:

  1. The redirectUri in your BaseIdpFlutterAuth constructor.
  2. The CFBundleURLSchemes array in Info.plist (iOS).
  3. The android:scheme attribute in AndroidManifest.xml (Android).
  4. 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.

Where to go next

On this page