JWTとsecure_storageでFlutterのログイン状態を管理する でトークンを保存し起動時に復元できるようにすると、次に詰まるのがアクセストークンの期限切れです。保護APIを叩いて 401 Unauthorized が返るたびにログイン画面へ戻していては、リフレッシュトークンを持っている意味がありません。この記事では dio の Interceptor を使い、リクエストにトークンを付け、401 を受けたら 1 回だけリフレッシュして元のリクエストを再試行し、リフレッシュにも失敗したらログアウトする最小構成を作ります。
題材は GET /api/protected を叩くだけのアプリです。実サーバーは用意せず、flutter run でもテストでも同じ偽バックエンドを差し込んで動かします。
1. ゴールと非対象
対象読者
- ログイン状態の保持までは実装し、
flutter run/flutter testを回した経験がある人 - アクセストークンの期限切れで 401 が返ったときの扱いを決めかねている人
httpからdioへ移って、共通処理を Interceptor に寄せたい人
この記事で到達する状態
dioのInterceptorでリクエストにAuthorizationを自動付与する- 401 応答を受けてリフレッシュし、元のリクエストを 1 回だけ再試行できる
- 同時に来た 401 でもリフレッシュを 1 回に抑える単発化の考え方
- リフレッシュに失敗したらトークンを捨ててログアウトへ落とせる
非対象
今回は 401 からの復帰に集中します。扱わないテーマは次のとおりです。
- リフレッシュエンドポイントのサーバー側実装
flutter_secure_storageへの保存処理(前提記事に譲り、ここはメモリ保管で示す)- 大量の並行リクエストに対する高度なキュー制御(最小の単発化にとどめる)
- トークンの有効期限設計やローテーション方針そのもの
2. 全体の流れ(401からリフレッシュ・再試行まで)
成功時は、401 をきっかけにリフレッシュと再試行が自動で挟まります。
sequenceDiagram
participant UI as 画面
participant Dio as Dio + AuthInterceptor
participant API as バックエンド
UI->>Dio: GET /protected
Dio->>API: Authorization: Bearer(期限切れ)
API-->>Dio: 401
Dio->>API: POST /auth/refresh
API-->>Dio: 200 新しいトークン
Dio->>API: GET /protected(新トークンで再試行)
API-->>Dio: 200
Dio-->>UI: 成功(呼び出し側は 401 を意識しない)
リフレッシュ自体が 401 になった場合は、トークンを破棄してログアウトへ落とします。呼び出し側のコードは dio.get('/protected') を書くだけで、401 の復帰は Interceptor が引き受ける形です。
3. プロジェクトを作成し、dio を追加する
3-1. 環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create dio_refresh
cd dio_refresh
以降のコマンドとファイル操作は、このプロジェクト直下で行います。
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. dio を追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add dio
pubspec.yaml に次の行が入ります(執筆時のバージョンは dio: ^5.9.2)。
dependencies:
dio: ^5.9.2
4. トークン保管庫とインターセプタを作る
flutter create が生成した lib/main.dart を、次の内容で書き換えます。トークン保管庫 TokenStore、認証 Interceptor AuthInterceptor、Dio を組み立てる buildAuthedDio、動作確認用の偽バックエンド FakeBackendAdapter、最小 UI までを 1 ファイルにまとめました。
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const TokenRefreshDemoApp());
}
/// アクセストークンとリフレッシュトークンの保管庫。
/// このサンプルはメモリ上に持つ。本番は flutter_secure_storage へ置き換える
/// (その場合は読み書きが非同期になる)。
class TokenStore {
TokenStore({String? accessToken, String? refreshToken})
: _accessToken = accessToken,
_refreshToken = refreshToken;
String? _accessToken;
String? _refreshToken;
String? get accessToken => _accessToken;
String? get refreshToken => _refreshToken;
void update({required String accessToken, required String refreshToken}) {
_accessToken = accessToken;
_refreshToken = refreshToken;
}
void clear() {
_accessToken = null;
_refreshToken = null;
}
}
/// リクエストにトークンを付け、401 を受けたらリフレッシュして元のリクエストを
/// 1 回だけ再試行する Interceptor。リフレッシュに失敗したらログアウトする。
class AuthInterceptor extends Interceptor {
AuthInterceptor({
required this.tokenStore,
required this.onLogout,
required Dio bareClient,
this.refreshPath = '/auth/refresh',
}) : _bareClient = bareClient;
final TokenStore tokenStore;
final VoidCallback onLogout;
final String refreshPath;
/// インターセプタを通さない素の Dio。リフレッシュと再試行に使い、再帰を防ぐ。
final Dio _bareClient;
/// 同時に来た 401 で何度もリフレッシュしないための単発化フラグ。
Future<bool>? _refreshing;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final String? token = tokenStore.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final bool shouldRefresh = err.response?.statusCode == 401 &&
err.requestOptions.extra['retried'] != true;
if (!shouldRefresh) {
handler.next(err);
return;
}
final bool refreshed = await _refreshOnce();
if (!refreshed) {
tokenStore.clear();
onLogout();
handler.next(err);
return;
}
try {
final RequestOptions options = err.requestOptions;
options.extra['retried'] = true;
options.headers['Authorization'] = 'Bearer ${tokenStore.accessToken}';
final Response<dynamic> response = await _bareClient.fetch<dynamic>(options);
handler.resolve(response);
} on DioException catch (error) {
handler.next(error);
}
}
Future<bool> _refreshOnce() {
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<bool> _doRefresh() async {
final String? refreshToken = tokenStore.refreshToken;
if (refreshToken == null) {
return false;
}
try {
final Response<dynamic> response = await _bareClient.post<dynamic>(
refreshPath,
data: <String, dynamic>{'refreshToken': refreshToken},
);
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
tokenStore.update(
accessToken: body['accessToken'] as String,
refreshToken: body['refreshToken'] as String,
);
return true;
} on DioException {
return false;
}
}
}
/// AuthInterceptor を載せた Dio を組み立てる。
/// テストでは [adapter] に偽バックエンドを渡し、本番では渡さず実通信にする。
Dio buildAuthedDio({
required TokenStore tokenStore,
required VoidCallback onLogout,
HttpClientAdapter? adapter,
String baseUrl = 'https://api.example.com',
}) {
final Dio bareClient = Dio(BaseOptions(baseUrl: baseUrl));
final Dio dio = Dio(BaseOptions(baseUrl: baseUrl));
if (adapter != null) {
dio.httpClientAdapter = adapter;
bareClient.httpClientAdapter = adapter;
}
dio.interceptors.add(
AuthInterceptor(
tokenStore: tokenStore,
onLogout: onLogout,
bareClient: bareClient,
),
);
return dio;
}
/// 動作確認用の偽バックエンド。実サーバーの代わりに `flutter run` とテストの両方で使う。
/// - `/protected`: Authorization が現在有効なトークンなら 200、違えば 401
/// - `/auth/refresh`: refreshSucceeds が true なら新トークンを返す、false なら 401
class FakeBackendAdapter implements HttpClientAdapter {
FakeBackendAdapter({
required this.validAccessToken,
required this.refreshSucceeds,
});
final String validAccessToken;
final bool refreshSucceeds;
int protectedCalls = 0;
int refreshCalls = 0;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
switch (options.path) {
case '/protected':
protectedCalls++;
if (options.headers['Authorization'] == 'Bearer $validAccessToken') {
return _json(<String, dynamic>{'message': 'protected ok'}, 200);
}
return _json(<String, dynamic>{'error': 'unauthorized'}, 401);
case '/auth/refresh':
refreshCalls++;
if (refreshSucceeds) {
return _json(<String, dynamic>{
'accessToken': validAccessToken,
'refreshToken': 'refresh-rotated',
}, 200);
}
return _json(<String, dynamic>{'error': 'invalid refresh token'}, 401);
default:
return _json(<String, dynamic>{'error': 'not found'}, 404);
}
}
ResponseBody _json(Map<String, dynamic> body, int statusCode) {
return ResponseBody.fromString(
jsonEncode(body),
statusCode,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>['application/json'],
},
);
}
@override
void close({bool force = false}) {}
}
class TokenRefreshDemoApp extends StatelessWidget {
const TokenRefreshDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Token Refresh Demo',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const TokenRefreshPage(),
);
}
}
class TokenRefreshPage extends StatefulWidget {
const TokenRefreshPage({super.key});
@override
State<TokenRefreshPage> createState() => _TokenRefreshPageState();
}
class _TokenRefreshPageState extends State<TokenRefreshPage> {
late TokenStore _store;
late Dio _dio;
bool _refreshExpired = false;
bool _loggedOut = false;
String _status = 'まだ呼んでいません。';
@override
void initState() {
super.initState();
_rebuildClient();
}
// 期限切れアクセストークンから始め、初回呼び出しでリフレッシュが走るようにする。
void _rebuildClient() {
_store = TokenStore(accessToken: 'access-expired', refreshToken: 'refresh-1');
_loggedOut = false;
_dio = buildAuthedDio(
tokenStore: _store,
onLogout: () => setState(() => _loggedOut = true),
adapter: FakeBackendAdapter(
validAccessToken: 'access-fresh',
refreshSucceeds: !_refreshExpired,
),
);
}
Future<void> _callProtected() async {
try {
final Response<dynamic> response = await _dio.get<dynamic>('/protected');
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
setState(() {
_status = '成功: ${body['message']} / token=${_store.accessToken}';
});
} on DioException catch (error) {
setState(() {
final String code = '${error.response?.statusCode ?? '通信失敗'}';
_status = _loggedOut
? '失敗($code) → リフレッシュできずログアウトしました。'
: '失敗($code) でした。';
});
}
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('dio トークンリフレッシュ')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'期限切れトークンで保護APIを呼ぶと、401 を受けて自動でリフレッシュし、'
'元のリクエストを再試行します。',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('リフレッシュトークンも失効させる'),
subtitle: const Text('オンにするとリフレッシュが失敗し、ログアウトします。'),
value: _refreshExpired,
onChanged: (bool value) {
setState(() {
_refreshExpired = value;
_rebuildClient();
_status = 'まだ呼んでいません。';
});
},
),
const SizedBox(height: 8),
FilledButton(
onPressed: _callProtected,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('保護APIを呼ぶ'),
),
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_status, style: theme.textTheme.titleMedium),
),
),
],
),
),
);
}
}
FakeBackendAdapter は実サーバーの代役です。本番では buildAuthedDio に adapter を渡さず、baseUrl を自分の API に向けるだけで、同じ Interceptor がそのまま動く形です。
コードのポイント
① onRequest でトークンを付ける
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final String? token = tokenStore.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
すべてのリクエストの前に Authorization ヘッダーを付けます。呼び出し側は個々のリクエストでトークンを意識せず、付与漏れも防げる形です。トークンが無い起動直後は付けずに通します。
② onError で 401 をリフレッシュへ回し、二重実行を防ぐ
final bool shouldRefresh = err.response?.statusCode == 401 &&
err.requestOptions.extra['retried'] != true;
if (!shouldRefresh) {
handler.next(err);
return;
}
final bool refreshed = await _refreshOnce();
// ...
Future<bool> _refreshOnce() {
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
401 で、まだ再試行していないリクエストだけをリフレッシュ対象にします。_refreshOnce() は進行中のリフレッシュがあればその Future を使い回すため、画面から同時に複数のリクエストが 401 になっても、リフレッシュは 1 回だけです。extra['retried'] で「このリクエストは再試行済み」と印を付け、再試行が再び 401 でもループしません。
③ 素の Dio で再試行し、再帰とループを断つ
final RequestOptions options = err.requestOptions;
options.extra['retried'] = true;
options.headers['Authorization'] = 'Bearer ${tokenStore.accessToken}';
final Response<dynamic> response = await _bareClient.fetch<dynamic>(options);
handler.resolve(response);
リフレッシュ後の再試行は、Interceptor を載せていない _bareClient で送ります。同じ Dio で再送すると Interceptor を再び通って読みづらくなるため、素の Dio に分けた形です。リフレッシュ呼び出しも同じ _bareClient を使うので、/auth/refresh 自体が 401 を巻き込むことはありません。
5. 動かして確認する
flutter run で起動すると、期限切れのアクセストークンを持った状態で立ち上がります。
「保護APIを呼ぶ」を押すと、内部で 401 → リフレッシュ → 再試行が走り、成功表示とともにトークンが更新されます。表示される token=access-fresh は、リフレッシュで受け取った新しいアクセストークンです。期限切れの access-expired のままなら、再試行は通っていません。
「リフレッシュトークンも失効させる」をオンにしてから押すと、リフレッシュが失敗し、ログアウト表示へ切り替わる動きです。
6. テストで 401→リフレッシュ→再試行とログアウトを確かめる
lib/main.dart を書き換えたことで、flutter create 直後に入っている test/widget_test.dart は元のカウンターアプリ前提のままになり、失敗します。この記事では Interceptor のテストに絞って進めるため、test/widget_test.dart は削除してかまいません。
そのうえで test/auth_interceptor_test.dart を次の内容で作成します。偽バックエンドを差し込み、復帰と失敗の両方を確かめる構成です。
import 'package:dio/dio.dart';
import 'package:dio_refresh/main.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AuthInterceptor', () {
test('401を受けたらリフレッシュし、元のリクエストを再試行して成功する', () async {
final TokenStore store = TokenStore(
accessToken: 'access-expired',
refreshToken: 'refresh-1',
);
var loggedOut = false;
final FakeBackendAdapter adapter = FakeBackendAdapter(
validAccessToken: 'access-fresh',
refreshSucceeds: true,
);
final Dio dio = buildAuthedDio(
tokenStore: store,
onLogout: () => loggedOut = true,
adapter: adapter,
);
final Response<dynamic> response = await dio.get<dynamic>('/protected');
expect(response.statusCode, 200);
expect((response.data as Map<String, dynamic>)['message'], 'protected ok');
// 期限切れで 1 回 401、再試行で 1 回の計 2 回呼ばれている。
expect(adapter.protectedCalls, 2);
expect(adapter.refreshCalls, 1);
// トークンが更新されている。
expect(store.accessToken, 'access-fresh');
expect(loggedOut, isFalse);
});
test('リフレッシュにも失敗したらトークンを捨ててログアウトする', () async {
final TokenStore store = TokenStore(
accessToken: 'access-expired',
refreshToken: 'refresh-expired',
);
var loggedOut = false;
final FakeBackendAdapter adapter = FakeBackendAdapter(
validAccessToken: 'access-fresh',
refreshSucceeds: false,
);
final Dio dio = buildAuthedDio(
tokenStore: store,
onLogout: () => loggedOut = true,
adapter: adapter,
);
await expectLater(
dio.get<dynamic>('/protected'),
throwsA(isA<DioException>()),
);
expect(loggedOut, isTrue);
expect(store.accessToken, isNull);
expect(store.refreshToken, isNull);
// 再試行はしないので protected は 1 回だけ。
expect(adapter.protectedCalls, 1);
expect(adapter.refreshCalls, 1);
});
test('有効なトークンがあればリフレッシュせずそのまま成功する', () async {
final TokenStore store = TokenStore(
accessToken: 'access-fresh',
refreshToken: 'refresh-1',
);
final FakeBackendAdapter adapter = FakeBackendAdapter(
validAccessToken: 'access-fresh',
refreshSucceeds: true,
);
final Dio dio = buildAuthedDio(
tokenStore: store,
onLogout: () {},
adapter: adapter,
);
final Response<dynamic> response = await dio.get<dynamic>('/protected');
expect(response.statusCode, 200);
expect(adapter.protectedCalls, 1);
expect(adapter.refreshCalls, 0);
});
});
}
flutter test を実行すると、3 件とも通ります。
00:00 +0: AuthInterceptor 401を受けたらリフレッシュし、元のリクエストを再試行して成功する
00:00 +1: AuthInterceptor リフレッシュにも失敗したらトークンを捨ててログアウトする
00:00 +2: AuthInterceptor 有効なトークンがあればリフレッシュせずそのまま成功する
00:00 +3: All tests passed!
protectedCalls が 2 であることが、再試行まで届いた証拠です。1 回目で 401、リフレッシュ後の 2 回目で 200 を受け取れています。失敗系では protectedCalls が 1 のままで、ログアウトとトークン破棄まで確認できます。偽バックエンドを差し替えるだけなので、実サーバーを立てずに 401 からの復帰経路を再現できます。
7. まとめ
トークンの自動更新は、dio の Interceptor へ寄せると呼び出し側が薄くなります。
onRequestでトークンを付け、付与漏れをなくすonErrorで 401 を捕まえ、単発リフレッシュ → 再試行 → 失敗時ログアウトへ分岐する- 再試行とリフレッシュは素の Dio で送り、再帰とループを避ける
まずは flutter pub add dio で 1 つの保護APIを Interceptor 経由に置き換え、401 の復帰を Interceptor へ移してみてください。トークンの保存先は JWTとsecure_storageでFlutterのログイン状態を管理する の flutter_secure_storage に、エラー表示は FlutterでREST APIを呼び出してJSONとエラーを扱う の受け止め方につなぐと、認証まわりの導線がそろいます。