Get started
API scopes
Integration guides
Features
Troubleshooting
Flutter
flutter_appauth wraps Google's AppAuth SDKs — the canonical OAuth client for native iOS and Android. PKCE, browser tabs, deep-link handling, and silent refresh are all handled by the underlying native code.
Use the Native application type in the developer portal — public client, no secret. Mobile apps cannot keep secrets safely.
Install
# pubspec.yaml
dependencies:
flutter_appauth: ^12.0.0
flutter_secure_storage: ^10.1.0
These are the current major versions on pub.dev as of writing — flutter_appauth 12 needs Flutter 3.38+ / Dart 3.10+, iOS 13 minimum, and Android minSdkVersion 24. The platform bumps are non-optional; see below.
Platform configuration
iOS
flutter_appauth 12 and flutter_secure_storage 10 both require iOS 13. The Podfile shipped by flutter create targets iOS 12, which builds but fails at link time with cryptic "Unsupported deployment target" errors.
# ios/Podfile
platform :ios, '13.0'
Android
// android/app/build.gradle
android {
defaultConfig {
minSdkVersion 24
manifestPlaceholders = [appAuthRedirectScheme: 'is.kenni.example']
}
}
The AppAuth Android plugin reads manifestPlaceholders[appAuthRedirectScheme] at manifest-merge time — it's how Android registers your custom URL scheme. Use a reverse-DNS form (is.kenni.example) — myapp collides with every other tutorial app on the device.
iOS URL scheme
<!-- ios/Runner/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>is.kenni.example</string>
</array>
</dict>
</array>
Register two URIs in the developer portal — is.kenni.example://callback as a Redirect URI and is.kenni.example://post-logout as a Post logout redirect URI. They're separate lists with separate lifecycles.
Configuration
The idiomatic Flutter pattern is --dart-define-from-file (stable since Flutter 3.7), with values surfaced via String.fromEnvironment. No runtime .env parser, no extra dependency.
// dart_defines.json (gitignored)
{
"KENNI_ISSUER": "https://idp.kenni.is/<team-domain>",
"KENNI_CLIENT_ID": "@<team-domain>/native",
"KENNI_REDIRECT_URI": "is.kenni.example://callback",
"KENNI_POST_LOGOUT_REDIRECT_URI": "is.kenni.example://post-logout"
}
// lib/src/config.dart
class KenniConfig {
static const issuer = String.fromEnvironment('KENNI_ISSUER');
static const clientId = String.fromEnvironment('KENNI_CLIENT_ID');
static const redirectUrl = String.fromEnvironment('KENNI_REDIRECT_URI');
static const postLogoutRedirectUrl =
String.fromEnvironment('KENNI_POST_LOGOUT_REDIRECT_URI');
static String get discoveryUrl => '$issuer/.well-known/openid-configuration';
static void assertConfigured() {
if (issuer.isEmpty || clientId.isEmpty || redirectUrl.isEmpty || postLogoutRedirectUrl.isEmpty) {
throw StateError(
'Missing KENNI_* dart-defines. Run with --dart-define-from-file=dart_defines.json',
);
}
}
}
Run with:
flutter run --dart-define-from-file=dart_defines.json
Sign-in flow
import 'dart:convert';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'src/config.dart';
final FlutterAppAuth _appAuth = const FlutterAppAuth();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
Future<AuthorizationTokenResponse?> signIn({bool forceReauth = false}) async {
try {
return await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
KenniConfig.clientId,
KenniConfig.redirectUrl,
discoveryUrl: KenniConfig.discoveryUrl,
scopes: const ['openid', 'profile', 'national_id', 'offline_access'],
promptValues: forceReauth ? const ['login'] : null,
),
);
} on FlutterAppAuthUserCancelledException {
// User dismissed the in-app browser — not an error to surface.
return null;
}
}
const FlutterAppAuth() is the right constructor — current versions are const-constructable, and the prefer_const_constructors lint (default-on with flutter_lints) flags the non-const form.
authorizeAndExchangeCode performs the authorization request, code exchange, and PKCE in a single call. The returned response contains accessToken, idToken, refreshToken, and accessTokenExpirationDateTime. FlutterAppAuthUserCancelledException fires when the user dismisses the in-app browser — catch it, don't let it crash to Crashlytics/Sentry as if it were a bug.
Storing tokens
Persist tokens on sign-in and round-trip them on launch — without this, every relaunch bounces the user back through Kenni even when their Kenni session is still valid.
const _tokensKey = 'kenni.tokens.v1';
Future<void> persist(AuthorizationTokenResponse res) async {
await _storage.write(
key: _tokensKey,
value: jsonEncode({
'accessToken': res.accessToken,
'idToken': res.idToken,
'refreshToken': res.refreshToken,
'expiresAt': res.accessTokenExpirationDateTime?.toIso8601String(),
}),
);
}
Future<Map<String, dynamic>?> loadTokens() async {
final raw = await _storage.read(key: _tokensKey);
return raw == null ? null : jsonDecode(raw) as Map<String, dynamic>;
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
KenniConfig.assertConfigured();
final tokens = await loadTokens(); // pre-fill auth state before runApp
runApp(MyApp(initialTokens: tokens));
}
flutter_secure_storage uses Keychain on iOS and EncryptedSharedPreferences on Android. Don't store tokens in SharedPreferences directly.
Showing the user's claims
Decode the id_token payload to display the user's name — the canonical "prove sign-in worked" UI.
Map<String, dynamic> decodeJwtPayload(String token) {
final parts = token.split('.');
final padded = base64Url.normalize(parts[1]);
return jsonDecode(utf8.decode(base64Url.decode(padded))) as Map<String, dynamic>;
}
// In a widget:
final claims = decodeJwtPayload(idToken);
Text('Welcome ${claims['name'] ?? claims['sub']}');
Refresh
Future<TokenResponse?> refresh(String refreshToken) {
return _appAuth.token(
TokenRequest(
KenniConfig.clientId,
KenniConfig.redirectUrl,
discoveryUrl: KenniConfig.discoveryUrl,
refreshToken: refreshToken,
grantType: 'refresh_token',
),
);
}
Sign out
RP-initiated logout has rough UX on iOS that Apple doesn't let you fix. flutter_appauth.endSession() uses ASWebAuthenticationSession under the hood, which shows a hardcoded "<App> wants to use 'kenni.is' to sign in" consent prompt — even on a sign-out flow. The wording is not configurable. Opening Safari directly via url_launcher only trades it for the OS-level "Open this page in '<App>'?" confirmation when Kenni redirects back to the custom scheme. The only clean fix is Universal Links: a real HTTPS domain you control + an apple-app-site-association file + the applinks: entitlement.
What real iOS apps ship: skip RP-initiated logout entirely on iOS. Clear local tokens and force prompt=login on the next sign-in via AuthorizationTokenRequest.promptValues = ['login']. Android Chrome Custom Tabs have neither prompt and the full flow is clean.
import 'dart:io' show Platform;
Future<void> signOut({required String idToken, String? refreshToken}) async {
// Clear local state first — a failed RP-initiated logout still leaves you signed out locally.
await _storage.deleteAll();
if (Platform.isIOS) {
// Skip endSession on iOS. Set a flag so the next signIn passes promptValues: ['login'].
return;
}
try {
await _appAuth.endSession(
EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: KenniConfig.postLogoutRedirectUrl,
discoveryUrl: KenniConfig.discoveryUrl,
),
);
} on FlutterAppAuthUserCancelledException {
// Already cleared locally, the user dismissing the browser is fine.
}
}
postLogoutRedirectUrl must be a different URI from your sign-in redirectUrl so the OS deep-link layer can tell sign-in completion apart from sign-out completion — register both separately in the developer portal.
See the No framework (curl) guide for the protocol-level reasoning.