はじめに (対象読者・この記事でわかること)
この記事は、Flutterで認証機能を実装したいが、ログイン状態をどう永続化すればよいか悩んでいる方向けです。特に、JWT(JSON Web Token)を使った認証を採用しているものの、トークンの保存場所や自動更新の仕組みがイメージできない方を想定しています。
読み終えると、以下のことができるようになります。 - secure_storageを使ったトークンの安全な保存方法 - Riverpodを使った認証状態の管理と画面遷移の制御 - アクセストークンの有効期限切れを検知し、リフレッシュトークンで自動更新する仕組みの実装 - iOS/Android両プラットフォームでキーチェーンエラーを回避するための設定
また、本記事では実際に商用アプリで運用しているコードベースを元に、ハマりどころを含めて詳細に解説します。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。 - Flutterの基本的なWidgetの扱い(StatelessWidget/StatefulWidget) - Dartのasync/awaitやFutureの概念 - JWTの仕組み(アクセストークン・リフレッシュトークンの違い)
ログイン状態保持の設計思想:セキュリティとUXの両立
モバイルアプリでログイン状態を保持する際、最も重要なのは「セキュリティ」と「UX(ユーザビエクスペリエンス)」のバランスです。例えば、毎回ログインを求められればセキュアですが、ユーザビリティは著しく低下します。逆に、トークンを平文でshared_preferencesに保存すれば利便性は高まりますが、不正なアプリに簡単に読み取られてしまいます。
そこで本記事では、以下の3層アーキテクチャを採用します。 1. 保存層:secure_storageで暗号化されたトークンを保存 2. 状態管理層:Riverpodで認証状態をリアクティブに管理 3. 更新層:dioのインターセプターで自動的にトークンをリフレッシュ
この設計により、ユーザはアプリを再起動しても自動でログイン状態が復元され、かつトークンが端末内で暗号化されて保存されるため、万が一端末を紛失しても第三者に簡単に漏洩することはありません。
secure_storage + Riverpodで実装する完全自動ログインシステム
ここからは、実際にコードを書きながら解説していきます。最終的には、起動時に自動でトークンを読み込み、有効であればホーム画面へ、無効であればログイン画面へ遷移する仕組みを完成させます。
Step1: 依存パッケージの追加とiOS/Androidの設定
まず、pubspec.yamlに以下のパッケージを追加します。
Yamldependencies: flutter_secure_storage: ^9.2.2 flutter_riverpod: ^2.5.1 dio: ^5.4.3 jwt_decoder: ^2.0.1
次に、iOSではKeychainの扱いに関する設定が必要です。ios/Runner/Runner.entitlementsに以下を追記します。
Xml<key>keychain-access-groups</key> <array> <string>$(AppIdentifierPrefix)com.example.myapp</string> </array>
AndroidではminSdkVersionを23以上に設定し、MainActivity.ktに以下を追加します。
Kotlinimport androidx.annotation.NonNull import io.flutter.embedding.android.FlutterFragmentActivity class MainActivity: FlutterFragmentActivity() { }
secure_storageはSDK18以下では暗号化がサポートされていないため、SDK23以上を必須とします。
Step2: トークン保存用のRepositoryを作成
次に、secure_storageへのアクセスを抽象化したAuthRepositoryを作成します。
Dartimport 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; const _keyAccessToken = 'accessToken'; const _keyRefreshToken = 'refreshToken'; class AuthRepository { const AuthRepository(this._secureStorage); final FlutterSecureStorage _secureStorage; Future<void> saveTokens({ required String accessToken, required String refreshToken, }) async { await Future.wait([ _secureStorage.write(key: _keyAccessToken, value: accessToken), _secureStorage.write(key: _keyRefreshToken, value: refreshToken), ]); } Future<String?> getAccessToken() => _secureStorage.read(key: _keyAccessToken); Future<String?> getRefreshToken() => _secureStorage.read(key: _keyRefreshToken); Future<bool> hasValidToken() async { final token = await getAccessToken(); if (token == null) return false; return !JwtDecoder.isExpired(token); } Future<void> clear() async { await _secureStorage.deleteAll(); } }
ポイントは、JwtDecoder.isExpired()で有効期限を事前にチェックしていることです。これにより、APIコールを送る前にトークンの有効性を判定できます。
Step3: 認証状態を管理するRiverpod Provider
続いて、RiverpodのStateNotifierProviderを使って認証状態を管理します。
Dartimport 'package:flutter_riverpod/flutter_riverpod.dart'; enum AuthStatus { unknown, authenticated, unauthenticated } final authRepositoryProvider = Provider( (_) => AuthRepository(const FlutterSecureStorage()), ); class AuthNotifier extends StateNotifier<AuthStatus> { AuthNotifier(this._authRepository) : super(AuthStatus.unknown) { _init(); } final AuthRepository _authRepository; Future<void> _init() async { final valid = await _authRepository.hasValidToken(); state = valid ? AuthStatus.authenticated : AuthStatus.unauthenticated; } Future<void> signIn(String email, String password) async { // 実際にはここでAPIをコール final tokens = await _fetchTokens(email, password); await _authRepository.saveTokens( accessToken: tokens.access, refreshToken: tokens.refresh, ); state = AuthStatus.authenticated; } Future<void> signOut() async { await _authRepository.clear(); state = AuthStatus.unauthenticated; } } final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>( (ref) => AuthNotifier(ref.read(authRepositoryProvider)), );
AuthStatus.unknownで初期化し、非同期でトークンの検証を行うことで、スプラッシュ画面を表示しながら次の画面を決定できます。
Step4: dioインターセプターで自動リフレッシュ
トークンの有効期限が切れた際に自動的に更新するため、dioのインターセプターを実装します。
Dartimport 'package:dio/dio.dart'; class AuthInterceptor extends Interceptor { AuthInterceptor(this._authRepository, this._dio); final AuthRepository _authRepository; final Dio _dio; @override Future<void> onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { final token = await _authRepository.getAccessToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override Future<void> onError( DioException err, ErrorInterceptorHandler handler, ) async { if (err.response?.statusCode == 401) { final refreshed = await _refreshToken(); if (refreshed) { // リトライ final req = err.requestOptions; final token = await _authRepository.getAccessToken(); req.headers['Authorization'] = 'Bearer $token'; try { final retried = await _dio.fetch(req); return handler.resolve(retried); } catch (_) { // リトライも失敗したらログイン画面へ return handler.reject(err); } } } handler.next(err); } Future<bool> _refreshToken() async { try { final refresh = await _authRepository.getRefreshToken(); if (refresh == null) return false; final res = await _dio.post('/auth/refresh', data: {'refresh': refresh}); final newAccess = res.data['access'] as String; await _authRepository.saveTokens( accessToken: newAccess, refreshToken: refresh, ); return true; } catch (_) { return false; } } }
これにより、401エラーが返ってきた際に自動的にリフレッシュを試み、成功すればそのままリトライ、失敗すればログイン画面へ誘導します。
ハマった点:iOSシミュレーターでKeychainエラーが発生
実装中、iOSシimulatorで実行すると以下のエラーが頻発しました。
FlutterSecureStorage: Cannot write to Keychain: Error Domain=Security Code=-34018
このエラーは、iOSシミュレーター特有のKeychainの制限によるものです。解決策は2つあります。
- 実機でテストする(最も簡単)
- 「Keychain Sharing」を有効にする
私は2を採用しました。XcodeでRunnerターゲットの「Signing & Capabilities」タブを開き、「+Capability」から「Keychain Sharing」を追加。これでシミュレータでも安定動作を確認できました。
解決策:FlutterSecureStorageのオプションを調整
加えて、FlutterSecureStorageのコンストラクタにオプションを渡すことで、より安定動作させることができます。
Dartconst storage = FlutterSecureStorage( iOptions: IOSOptions( accessibility: KeychainAccessibility.first_unlock_this_device, accountName: 'myapp_auth', ), aOptions: AndroidOptions( encryptedSharedPreferences: true, keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding, storageCipherAlgorithm: StorageCipherAlgorithm.AES_GCM_NoPadding, ), );
first_unlock_this_deviceにすることで、端末起動後最初のロック解除後のみKeychainにアクセスできるため、バックグラウンド復帰時のクラッシュを防げます。
まとめ
本記事では、Flutterでログイン状態を安全かつ自動的に保持する手法を解説しました。
- secure_storageを使った暗号化されたトークン保存
- Riverpodによるリアクティブな認証状態管理
- dioインターセプターによる自動トークンリフレッシュ
- iOS/Androidで発生するKeychain関連エラーの回避法
この実装を通して、ユーザはアプリ再起動後も自動でログイン状態が復元され、かつトークン有効期限切れを検知して自動更新されるため、UXが大幅に向上します。また、トークンが暗号化されて端末内に保存されるため、セキュリティ面でも高水準を保てます。
今後は、バイオメトリクス認証(Face ID / 指紋)を組み合わせた「本人確認省略+自動ログイン」や、PINコードでの簡易認証など、セキュリティと利便性を更に高める実装パターンを紹介していく予定です。
参考資料
