Flutterでgo_routerの認証ガードを実装する(redirect最小構成) の次に詰まりやすいのが、ログイン状態の保存先です。認証ガードの記事では in-memory でログイン状態を持ちましたが、アプリを再起動すると未ログインへ戻ります。この記事では http でログイン API を呼び、受け取った JWT を flutter_secure_storage へ保存し、起動時復元とログアウト時の破棄までを go_router と合わせて最小構成で確認します。
1. ゴールと非対象
対象読者
- Flutter の環境構築、REST API 通信、
go_routerの認証ガード、設定保存の入口までは終わっている人 - JWT を受け取ったあと、どこへ保存してどう復元すればよいかがまだ曖昧な人
- リフレッシュトークンへ進む前に、まずはログイン状態保持の最小形を手元で動かしたい人
この記事で到達する状態
POST /loginで受け取った JWT を secure storage へ保存できる- アプリ起動時に保存済みトークンを読み、ログイン済み画面へ復元できる
GoRouterとrefreshListenableを使って、ログイン前後とログアウト後の遷移をまとめられる- ログアウト時にトークンを削除し、
/loginへ戻せる
非対象
- リフレッシュトークンの再発行
- JWT の署名検証やクレーム解析
- Riverpod や Bloc を使った本格的な構成
- 本番バックエンドや HTTPS 証明書の設定
今回は「JWT を受け取って保存し、起動時に復元する」ところに絞ります。トークン期限切れの扱いは別の段階です。先に、secure storage をどこで読み書きするかを固めます。
2. 先にログイン状態保持の流れを掴む
今回の流れは次の通りです。
flowchart TD
A[アプリ起動] --> B[SessionController.restoreSession]
B --> C{secure storage に token があるか}
C -->|ある| D[ログイン済みとして router を再評価]
C -->|ない| E["/login を表示"]
E --> F[メールとパスワードを入力]
F --> G[POST /login]
G --> H{200 OK か}
H -->|はい| I[token を secure storage へ保存]
I --> D
H -->|いいえ| J[エラー表示]
D --> K["/orders を表示"]
K --> L[ログアウト]
L --> M[token を削除]
M --> E
見る場所は 4 つです。
- ログイン API が token を返すか
- token を secure storage に保存できたか
- 起動時に token を読み、ログイン済みへ戻せるか
- ログアウトで token を削除できたか
Android Emulator からホスト PC のローカル API を叩くときは http://10.0.2.2:8080 を使います。Windows デスクトップ実行や Web なら http://127.0.0.1:8080 を使います。この記事のコードもこの差を baseUrl で吸収する構成です。
ここで保存する JWT は、あくまで「サーバーが返した access token を次回起動まで保持する」ためのものです。期限切れ時にどう更新するかは次の段階で考えます。
3. プロジェクトを作成し、依存を追加する
3-1. Flutter の環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create my_login_state_app
cd my_login_state_app
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. パッケージを追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add http
flutter pub add flutter_secure_storage
flutter pub add go_router
役割は次の通りです。
http: ログイン API を呼ぶflutter_secure_storage: JWT を通常設定と分けて保存するgo_router: ログイン状態に応じて/loginと/ordersを切り替える
3-5. Android では cleartext HTTP を許可する
今回のモック API の URL は http://10.0.2.2:8080 です。Android 9 以降では cleartext HTTP が既定で拒否されるため、android/app/src/main/AndroidManifest.xml の <application> へ android:usesCleartextTraffic="true" を追加します。
<application
android:label="my_login_state_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
この設定を入れないままログインボタンを押すと、NetworkSecurityException で止まります。
4. モック認証 API を tool/mock_auth_api.dart に作る
まず、JWT を返す最小 API をローカルで用意します。今回は Flutter に含まれる Dart SDK だけで動くよう、tool ディレクトリを作成して tool/mock_auth_api.dart を追加します。
tool/mock_auth_api.dart は次の内容で作成します。
このファイルは、ログイン API の最小往復だけをローカルで再現するモックサーバーです。注目点は、POST /login にだけ応答し、成功時は token と利用者情報、失敗時は 401 を返すことです。
import 'dart:convert';
import 'dart:io';
const String allowedEmail = 'worker@example.com';
const String allowedPassword = 'pass1234';
Future<void> main() async {
final HttpServer server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
8080,
);
stdout.writeln('Mock auth API listening on http://127.0.0.1:8080');
await for (final HttpRequest request in server) {
request.response.headers.contentType = ContentType.json;
request.response.headers.add('Access-Control-Allow-Origin', '*');
request.response.headers.add('Access-Control-Allow-Headers', 'Content-Type');
request.response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS');
if (request.method == 'OPTIONS') {
request.response.statusCode = HttpStatus.noContent;
await request.response.close();
continue;
}
if (request.method == 'POST' && request.uri.path == '/login') {
await _handleLogin(request);
continue;
}
request.response.statusCode = HttpStatus.notFound;
request.response.write(
jsonEncode(<String, dynamic>{
'message': 'Not Found',
}),
);
await request.response.close();
}
}
Future<void> _handleLogin(HttpRequest request) async {
final String body = await utf8.decoder.bind(request).join();
final Map<String, dynamic> json = jsonDecode(body) as Map<String, dynamic>;
final String email = json['email']?.toString() ?? '';
final String password = json['password']?.toString() ?? '';
if (email != allowedEmail || password != allowedPassword) {
request.response.statusCode = HttpStatus.unauthorized;
request.response.write(
jsonEncode(<String, dynamic>{
'message': 'メールアドレスまたはパスワードが違います。',
}),
);
await request.response.close();
return;
}
request.response.statusCode = HttpStatus.ok;
request.response.write(
jsonEncode(<String, dynamic>{
'access_token': 'header.payload.signature-demo-token',
'user': <String, dynamic>{
'name': '倉庫担当A',
'email': allowedEmail,
},
}),
);
await request.response.close();
}
コードのポイント
① 受け付ける導線を POST /login に絞っている
await for (final HttpRequest request in server) {
request.response.headers.contentType = ContentType.json;
request.response.headers.add('Access-Control-Allow-Origin', '*');
request.response.headers.add('Access-Control-Allow-Headers', 'Content-Type');
request.response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS');
if (request.method == 'OPTIONS') {
request.response.statusCode = HttpStatus.noContent;
await request.response.close();
continue;
}
if (request.method == 'POST' && request.uri.path == '/login') {
await _handleLogin(request);
continue;
}
この記事で確認したいのはログイン往復だけなので、受け口を POST /login に限定しています。ルーティングを増やさないことで、Flutter 側の保存ロジックと API 側の責務を切り分けやすくしています。
② 認証失敗時は 401 とメッセージを返す
if (email != allowedEmail || password != allowedPassword) {
request.response.statusCode = HttpStatus.unauthorized;
request.response.write(
jsonEncode(<String, dynamic>{
'message': 'メールアドレスまたはパスワードが違います。',
}),
);
await request.response.close();
return;
}
失敗時に 401 とメッセージを返しているため、Flutter 側では資格情報エラーを通信失敗と分けて扱えます。ログイン API として最低限ほしい分岐だけを先に固定している形です。
③ 認証成功時だけ token と user を返す
request.response.statusCode = HttpStatus.ok;
request.response.write(
jsonEncode(<String, dynamic>{
'access_token': 'header.payload.signature-demo-token',
'user': <String, dynamic>{
'name': '倉庫担当A',
'email': allowedEmail,
},
}),
成功時に返すのは疑似的な JWT と利用者情報だけで、署名検証や有効期限判定は扱っていません。この記事では「サーバーが token を返す」「Flutter が保存する」という往復だけを再現し、失敗時は 401 とメッセージで切り分ける構成です。
5. lib/main.dart にログイン保持の最小構成をまとめる
続いて、Flutter 側で token を保存し、起動時に復元する処理を lib/main.dart にまとめます。
flutter create で生成された lib/main.dart は、いったん次の内容に書き換えます。
このファイルは、ログイン状態の復元、認証 API 呼び出し、secure storage への保存、go_router による画面切り替えを 1 本で確認するサンプルです。注目点は、SessionController を中心に router と保存処理を連動させていることです。
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(MyApp(sessionController: SessionController()));
}
class MyApp extends StatefulWidget {
const MyApp({super.key, required this.sessionController});
final SessionController sessionController;
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final GoRouter _router = GoRouter(
initialLocation: '/boot',
refreshListenable: widget.sessionController,
redirect: (BuildContext context, GoRouterState state) {
final SessionStatus status = widget.sessionController.status;
final bool isBootRoute = state.matchedLocation == '/boot';
final bool isLoginRoute = state.matchedLocation == '/login';
if (status == SessionStatus.unknown) {
return isBootRoute ? null : '/boot';
}
if (status != SessionStatus.unknown && isBootRoute) {
return status == SessionStatus.authenticated ? '/orders' : '/login';
}
if (status == SessionStatus.unauthenticated && !isLoginRoute) {
return Uri(
path: '/login',
queryParameters: <String, String>{
'from': state.uri.toString(),
},
).toString();
}
if (status == SessionStatus.authenticated && isLoginRoute) {
return _safeFrom(state.uri.queryParameters['from']);
}
return null;
},
routes: <RouteBase>[
GoRoute(
path: '/boot',
builder: (BuildContext context, GoRouterState state) {
return const BootPage();
},
),
GoRoute(
path: '/login',
builder: (BuildContext context, GoRouterState state) {
final String from = _safeFrom(state.uri.queryParameters['from']);
return LoginPage(
sessionController: widget.sessionController,
from: from,
);
},
),
GoRoute(
path: '/orders',
builder: (BuildContext context, GoRouterState state) {
return OrdersPage(sessionController: widget.sessionController);
},
),
],
);
@override
void initState() {
super.initState();
widget.sessionController.restoreSession();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'JWT login state demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
routerConfig: _router,
);
}
}
String _safeFrom(String? from) {
if (from == null || from.isEmpty || from == '/login') {
return '/orders';
}
return from;
}
enum SessionStatus {
unknown,
unauthenticated,
authenticated,
}
class LoginResult {
const LoginResult({
required this.accessToken,
required this.userName,
required this.email,
});
final String accessToken;
final String userName;
final String email;
factory LoginResult.fromJson(Map<String, dynamic> json) {
final Map<String, dynamic> user = json['user'] as Map<String, dynamic>;
return LoginResult(
accessToken: json['access_token'].toString(),
userName: user['name'].toString(),
email: user['email'].toString(),
);
}
}
class AuthApiClient {
AuthApiClient({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
String get baseUrl {
if (kIsWeb) {
return 'http://127.0.0.1:8080';
}
if (defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8080';
}
return 'http://127.0.0.1:8080';
}
Future<LoginResult> login({
required String email,
required String password,
}) async {
final http.Response response = await _client
.post(
Uri.parse('$baseUrl/login'),
headers: <String, String>{
'Content-Type': 'application/json',
},
body: jsonEncode(<String, String>{
'email': email,
'password': password,
}),
)
.timeout(const Duration(seconds: 5));
final Map<String, dynamic> json =
jsonDecode(response.body) as Map<String, dynamic>;
if (response.statusCode != 200) {
throw AuthException(json['message']?.toString() ?? 'ログインに失敗しました。');
}
return LoginResult.fromJson(json);
}
}
class AuthException implements Exception {
const AuthException(this.message);
final String message;
@override
String toString() => message;
}
class TokenStore {
TokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const String accessTokenKey = 'auth.accessToken';
static const String userNameKey = 'auth.userName';
static const String emailKey = 'auth.email';
final FlutterSecureStorage _storage;
Future<void> save(LoginResult result) async {
await _storage.write(key: accessTokenKey, value: result.accessToken);
await _storage.write(key: userNameKey, value: result.userName);
await _storage.write(key: emailKey, value: result.email);
}
Future<SavedSession?> load() async {
final String? accessToken = await _storage.read(key: accessTokenKey);
if (accessToken == null || accessToken.isEmpty) {
return null;
}
final String userName = await _storage.read(key: userNameKey) ?? '担当者';
final String email = await _storage.read(key: emailKey) ?? '';
return SavedSession(
accessToken: accessToken,
userName: userName,
email: email,
);
}
Future<void> clear() async {
await _storage.delete(key: accessTokenKey);
await _storage.delete(key: userNameKey);
await _storage.delete(key: emailKey);
}
}
class SavedSession {
const SavedSession({
required this.accessToken,
required this.userName,
required this.email,
});
final String accessToken;
final String userName;
final String email;
}
class SessionController extends ChangeNotifier {
SessionController({AuthApiClient? apiClient, TokenStore? tokenStore})
: _apiClient = apiClient ?? AuthApiClient(),
_tokenStore = tokenStore ?? TokenStore();
final AuthApiClient _apiClient;
final TokenStore _tokenStore;
SessionStatus _status = SessionStatus.unknown;
SavedSession? _session;
bool _isBusy = false;
SessionStatus get status => _status;
SavedSession? get session => _session;
bool get isBusy => _isBusy;
Future<void> restoreSession() async {
final SavedSession? savedSession = await _tokenStore.load();
_session = savedSession;
_status = savedSession == null
? SessionStatus.unauthenticated
: SessionStatus.authenticated;
notifyListeners();
}
Future<void> login({
required String email,
required String password,
}) async {
_isBusy = true;
notifyListeners();
try {
final LoginResult result = await _apiClient.login(
email: email,
password: password,
);
await _tokenStore.save(result);
_session = SavedSession(
accessToken: result.accessToken,
userName: result.userName,
email: result.email,
);
_status = SessionStatus.authenticated;
} finally {
_isBusy = false;
notifyListeners();
}
}
Future<void> logout() async {
_isBusy = true;
notifyListeners();
try {
await _tokenStore.clear();
_session = null;
_status = SessionStatus.unauthenticated;
} finally {
_isBusy = false;
notifyListeners();
}
}
}
class BootPage extends StatelessWidget {
const BootPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CircularProgressIndicator(),
SizedBox(height: 16),
Text('保存済みセッションを確認しています...'),
],
),
),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({
super.key,
required this.sessionController,
required this.from,
});
final SessionController sessionController;
final String from;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController _emailController =
TextEditingController(text: 'worker@example.com');
final TextEditingController _passwordController =
TextEditingController(text: 'pass1234');
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_errorMessage = null;
});
try {
await widget.sessionController.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (!mounted) {
return;
}
context.go(widget.from);
} on AuthException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = '接続に失敗しました。詳細: $error';
});
}
}
@override
Widget build(BuildContext context) {
final bool isBusy = widget.sessionController.isBusy;
return Scaffold(
appBar: AppBar(title: const Text('ログイン')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ListView(
padding: const EdgeInsets.all(24),
children: <Widget>[
Text(
'JWT を受け取り、secure storage に保存します。',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'モック API の初期値は worker@example.com / pass1234 です。',
),
const SizedBox(height: 24),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'メールアドレス',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'パスワード',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
FilledButton.icon(
onPressed: isBusy ? null : _submit,
icon: isBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Text('ログインする'),
),
],
),
),
),
);
}
}
class OrdersPage extends StatelessWidget {
const OrdersPage({super.key, required this.sessionController});
final SessionController sessionController;
@override
Widget build(BuildContext context) {
final SavedSession? session = sessionController.session;
final String token = session?.accessToken ?? '';
final String tokenSummary =
token.length <= 18 ? token : '${token.substring(0, 9)} ... ${token.substring(token.length - 6)}';
return Scaffold(
appBar: AppBar(
title: const Text('注文一覧'),
actions: <Widget>[
TextButton(
onPressed: sessionController.isBusy
? null
: () async {
await sessionController.logout();
if (context.mounted) {
context.go('/login');
}
},
child: const Text('ログアウト'),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'保存済みセッション',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text('担当者: ${session?.userName ?? '-'}'),
Text('メール: ${session?.email ?? '-'}'),
Text('token: $tokenSummary'),
const SizedBox(height: 8),
const Text(
'ここに token の全体を出す必要はありません。今回は保存済みであることだけ確認します。',
),
],
),
),
),
const SizedBox(height: 16),
const _OrderCard(
orderNo: 'SO-1001',
customer: '東京商事',
status: '出荷待ち',
),
const SizedBox(height: 12),
const _OrderCard(
orderNo: 'SO-1002',
customer: '大阪物産',
status: 'ピッキング中',
),
],
),
);
}
}
class _OrderCard extends StatelessWidget {
const _OrderCard({
required this.orderNo,
required this.customer,
required this.status,
});
final String orderNo;
final String customer;
final String status;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(orderNo),
subtitle: Text(customer),
trailing: Text(status),
),
);
}
}
コードのポイント
① router の redirect がログイン状態に応じて遷移先を決める
class _MyAppState extends State<MyApp> {
late final GoRouter _router = GoRouter(
initialLocation: '/boot',
refreshListenable: widget.sessionController,
redirect: (BuildContext context, GoRouterState state) {
final SessionStatus status = widget.sessionController.status;
final bool isBootRoute = state.matchedLocation == '/boot';
final bool isLoginRoute = state.matchedLocation == '/login';
if (status == SessionStatus.unknown) {
return isBootRoute ? null : '/boot';
}
if (status == SessionStatus.unauthenticated && !isLoginRoute) {
return Uri(
path: '/login',
queryParameters: <String, String>{
'from': state.uri.toString(),
},
).toString();
}
refreshListenable に SessionController を渡すと、ログイン状態が変わった直後に router が再評価されます。redirect で見るのは /boot、/login、/orders の 3 状態だけです。画面側に認証分岐を散らさずに済みます。
② SessionController が復元、保存、破棄を一元化する
class SessionController extends ChangeNotifier {
SessionController({AuthApiClient? apiClient, TokenStore? tokenStore})
: _apiClient = apiClient ?? AuthApiClient(),
_tokenStore = tokenStore ?? TokenStore();
final AuthApiClient _apiClient;
final TokenStore _tokenStore;
SessionStatus _status = SessionStatus.unknown;
SavedSession? _session;
bool _isBusy = false;
Future<void> restoreSession() async {
final SavedSession? savedSession = await _tokenStore.load();
_session = savedSession;
_status = savedSession == null
? SessionStatus.unauthenticated
: SessionStatus.authenticated;
notifyListeners();
}
Future<void> login({
required String email,
required String password,
}) async {
_isBusy = true;
notifyListeners();
try {
final LoginResult result = await _apiClient.login(
email: email,
password: password,
);
await _tokenStore.save(result);
restoreSession() を起動直後に呼べば、secure storage の token 有無から SessionStatus を決められます。login() は保存成功後に authenticated へ切り替え、logout() は token を削除して router が /login へ戻れる状態を作ります。認証状態の更新点を 1 か所に寄せられる構成です。
TokenStore は token だけでなく、画面表示に使う利用者名とメールも一緒に保存しています。厳密には user profile を毎回 API から取り直す設計もあります。ただ、最小構成では「復元後に画面へ出す最低限の要約」を一緒に持つほうが挙動を追いやすくなります。
6. モック API とアプリを起動して確認する
まず 1 つ目のターミナルでモック API を起動します。
dart run tool/mock_auth_api.dart
続いて 2 つ目のターミナルでアプリを起動します。
flutter run
初回起動では /login が表示されます。ここではモック API の初期値である worker@example.com / pass1234 を入力した状態を載せます。
確認手順は次の順です。
- 初回起動で
/loginが表示されることを確認する worker@example.com/pass1234でログインし、注文一覧へ進むことを確認する- アプリをいったん終了して再度起動し、ログイン画面ではなく注文一覧へ戻ることを確認する
- ログアウトし、
/loginへ戻ったあと再起動しても未ログインのままであることを確認する
ログインに成功すると、注文一覧の先頭に保存済みセッションカードが表示されます。担当者名、メール、token の要約が見えていれば、保存と復元の流れを画面上でも追えます。
再起動前後の注文一覧と、ログアウト後に戻る /login 画面は見た目が同じです。そこでスクリーンショットは代表 2 画面に絞り、再起動の確認は本文で補います。アプリをいったん終了して再起動しても、secure storage に保存した token を起動時に読み直すため、注文一覧へ戻ります。ログアウト後は token を削除しているため、再起動しても注文一覧へは戻らず、未ログイン状態のまま /login が表示されます。
誤った資格情報を入れた場合は 401 が返り、ログイン画面にエラーメッセージが残ります。ここまで確認できれば、保存、復元、破棄の 3 点は揃っています。
7. まとめ
JWT を受け取って secure storage に保存し、起動時に復元してログイン状態を保つ最小構成です。この構成では、秘密値を通常設定と分けて保存し、起動時復元とログアウト時の破棄を SessionController に集約できます。
次は、期限切れ時の自動再試行やリフレッシュトークンへ進む流れです。go_router の route 分岐だけを先に整理したい場合は Flutterでgo_routerの認証ガードを実装する(redirect最小構成) を、秘密値と通常設定の保存先を整理し直したい場合は Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け) を合わせて読むと流れがつながります。