Last updated: September 27, 2026
After completing this codelab, students will be able to:
getToken, onTokenRefresh);Three common industry login patterns:
| Pattern | How it works (brief) | When to use it |
|---|---|---|
Firebase Auth (email/Google) | The Firebase SDK exchanges credentials for a JWT ID token verified by the backend | Campus apps that need fast login without building their own auth server |
JWT + refresh token (own REST API) | The server issues a short-lived access token plus a long-lived refresh token | Your own campus backend |
OAuth (Google Login) | The app receives an authorization code, exchanged for tokens via the provider | Social login / campus SSO |
This codelab uses a mock auth provider + simulated JWT so it runs without a backend, and shows exactly where Firebase Auth plugs in. The repository and token-refresh patterns are identical, so migrating to Firebase Auth only replaces the token source.
Authorization: Bearer ....Login --> access (15 min) + refresh (7 days) stored securely
API request --Bearer access header--> 401 expired?
Yes --> exchange refresh --> new access --> retry request once
Refresh also expired --> logout, back to /login
App Server (backend) --sends to--> Firebase Cloud Messaging
FCM --pushes to--> Android / iOS devices
App --registers token--> Backend (stores token per user)
The mandatory flow: (1) the app requests notification permission, (2) the app fetches a registration token via FirebaseMessaging.instance.getToken(), (3) the token is sent to the backend and stored per user, (4) the backend calls the FCM API to send messages to a token or topic.
| Type | Content | System behavior |
|---|---|---|
notification | Display title + body | Android shows it automatically in background/terminated; foreground needs manual display |
data | Free key-value pairs, e.g. {route: /announcement/3} | Always delivered to the app handler; never shown automatically |
Practical rule for the Campus Notification App: always send combined notification + data. notification carries human-readable text, data.route carries the click destination deep link.
| State | Meaning | Working handler |
|---|---|---|
| Foreground | App is open | FirebaseMessaging.onMessage (display manually via local notification) |
| Background | App is minimized | Automatic system banner + onMessageOpenedApp on click |
| Terminated | App is killed | getInitialMessage() when opened from the notification |
flutter create campus_notify
cd campus_notify
flutter pub add flutter_riverpod go_router dio flutter_secure_storage
flutter pub add firebase_core firebase_messaging flutter_local_notifications
Folder structure:
lib/
├── main.dart
├── data/
│ ├── auth_repository.dart
│ ├── token_store.dart
│ └── api_client.dart
├── providers/
│ └── auth_provider.dart
├── messaging/
│ └── push_service.dart
└── pages/
├── login_page.dart
├── home_page.dart
└── announcement_page.dart
Create lib/data/token_store.dart. All tokens enter and leave only through this class:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStore {
TokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
static const _accessKey = 'access_token';
static const _refreshKey = 'refresh_token';
Future<void> save({required String access, required String refresh}) async {
await _storage.write(key: _accessKey, value: access);
await _storage.write(key: _refreshKey, value: refresh);
}
Future<String?> readAccess() => _storage.read(key: _accessKey);
Future<String?> readRefresh() => _storage.read(key: _refreshKey);
Future<void> clear() => _storage.deleteAll();
}
Create lib/data/auth_repository.dart:
class AuthSession {
const AuthSession({required this.access, required this.refresh});
final String access;
final String refresh;
}
class AuthRepository {
// REPLACE this point with FirebaseAuth.instance.signInWithEmailAndPassword
// or GoogleSignIn once your Firebase backend is ready.
Future<AuthSession> login(
{required String email, required String password}) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!email.contains('@') || password.length < 6) {
throw Exception('Invalid email or password');
}
// Simulated JWT: header.payload.signature (never parse manually
// in production, use server-side verification).
return AuthSession(
access: 'mock-access-for-$email',
refresh: 'mock-refresh-for-$email',
);
}
Future<String> refresh(String refreshToken) async {
await Future.delayed(const Duration(milliseconds: 300));
if (refreshToken.isEmpty) throw Exception('Refresh token missing');
return 'mock-access-renewed-${DateTime.now().millisecondsSinceEpoch}';
}
}
Create lib/data/api_client.dart. The interceptor retries once after a 401 by refreshing, then replays the request:
import 'package:dio/dio.dart';
import 'auth_repository.dart';
import 'token_store.dart';
Dio buildApiClient(TokenStore store, AuthRepository auth) {
final dio = Dio(BaseOptions(baseUrl: 'https://example-campus-api.test'));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final access = await store.readAccess();
if (access != null) {
options.headers['Authorization'] = 'Bearer $access';
}
handler.next(options);
},
onError: (e, handler) async {
if (e.response?.statusCode == 401) {
final refresh = await store.readRefresh();
if (refresh == null) return handler.next(e);
try {
final renewed = await auth.refresh(refresh);
await store.save(access: renewed, refresh: refresh);
final retry = await dio.fetch(
e.requestOptions..headers['Authorization'] = 'Bearer $renewed',
);
return handler.resolve(retry);
} catch (_) {
await store.clear(); // refresh dead too -> force re-login
}
}
handler.next(e);
},
));
return dio;
}
Example lib/providers/auth_provider.dart and the GoRouter guard in main.dart:
final authStateProvider =
AsyncNotifierProvider<AuthNotifier, bool>(AuthNotifier.new);
class AuthNotifier extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
final token = await ref.watch(tokenStoreProvider).readAccess();
return token != null;
}
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final session = await ref
.read(authRepositoryProvider)
.login(email: email, password: password);
await ref
.read(tokenStoreProvider)
.save(access: session.access, refresh: session.refresh);
return true;
});
}
Future<void> logout() async {
await ref.read(tokenStoreProvider).clear();
ref.invalidateSelf();
}
}
GoRouter(
redirect: (context, state) {
final loggedIn =
container.read(authStateProvider).value ?? false;
final goingLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingLogin) return '/login';
if (loggedIn && goingLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/announcement/:id',
builder: (_, s) =>
AnnouncementPage(id: s.pathParameters['id'] ?? ''),
),
],
);
applicationId.google-services.json into android/app/ and follow the FCM Flutter client guide (apply the google-services plugin and dependencies). For iOS add GoogleService-Info.plist.firebase_core is initialized before runApp: await Firebase.initializeApp().Android 13+ and iOS require runtime permission. Create lib/messaging/push_service.dart:
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
final _local = FlutterLocalNotificationsPlugin();
Future<bool> requestNotificationPermission() async {
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true, badge: true, sound: true,
announcement: false, carPlay: false, criticalAlert: false,
);
return settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional;
}
Future<void> initLocalNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings();
await _local.initialize(
const InitializationSettings(android: android, iOS: ios),
onDidReceiveNotificationResponse: (response) {
// Foreground banner click -> forward payload to the router.
pendingDeepLink = response.payload;
},
);
}
String? pendingDeepLink;
Future<void> initFcmToken({required Future<void> Function(String token) onToken}) async {
// 1. Fetch the current token and send it to the backend.
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await onToken(token);
// 2. Tokens can change (reinstall, data wipe, security rotation).
// This listener is MANDATORY, otherwise the backend keeps a stale token.
FirebaseMessaging.instance.onTokenRefresh.listen(onToken);
// 3. Subscribe to the campus topic (e.g. all students of a cohort).
await FirebaseMessaging.instance.subscribeToTopic('campus-announcement');
}
Example of sending the token to your backend (replace the URL with the campus API):
await initFcmToken(onToken: (token) async {
await dio.post('/devices', data: {'fcm_token': token, 'platform': 'android'});
});
title and body, targeting your Android app.screenshots/fcm-console-test.png.The background handler must be a top-level function (not a class method) because it runs in a separate isolate:
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Do not touch BuildContext / Riverpod here.
// Job: log / persist lightly. Navigation happens on click.
}
void registerBackgroundHandler() {
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}
Backend payload (example JSON via FCM HTTP v1):
{
"message": {
"topic": "campus-announcement",
"notification": {
"title": "Schedule changed",
"body": "Mobile class moved to Room A2 at 1:00 PM"
},
"data": {
"route": "/announcement/3",
"id": "3"
}
}
}
void listenForeground(void Function(String route) go) {
// Foreground: the system shows NO banner automatically,
// so display one manually via a local notification.
FirebaseMessaging.onMessage.listen((message) async {
final route = message.data['route'] ?? '/';
const androidDetails = AndroidNotificationDetails(
'announcement', 'Campus Announcements',
importance: Importance.high, priority: Priority.high,
);
await _local.show(
message.hashCode,
message.notification?.title ?? 'Announcement',
message.notification?.body ?? '',
const NotificationDetails(android: androidDetails),
payload: route,
);
});
// Background -> tapped.
FirebaseMessaging.onMessageOpenedApp.listen((message) {
go(message.data['route'] ?? '/');
});
}
Future<void> handleTerminated(void Function(String route) go) async {
// Terminated -> opened from a notification.
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) go(initial.data['route'] ?? '/');
if (pendingDeepLink != null) go(pendingDeepLink!);
}
Test all three states with the same payload and fill in this table in the README:
| State | Expected | How to test |
|---|---|---|
| Foreground | Local banner appears, tap routes to /announcement/3 | App open, send from console/backend |
| Background | System banner appears, tap routes correctly | Press Home, send, tap banner |
| Terminated | App opens to the right route via getInitialMessage | Swipe-close the app, send, tap banner |
// Subscribe / unsubscribe from code:
await FirebaseMessaging.instance.subscribeToTopic('campus-announcement');
await FirebaseMessaging.instance.unsubscribeFromTopic('campus-announcement');
Topic rules: names contain no spaces; use them for broadcasts (all students, one class, one club). For personal messages (grades, bills) always use a device token, never a topic.
AI may draft the initial FCM service and auth boilerplate, but you prove the behavior. The most expensive FCM bugs (stale tokens, misrouted clicks, duplicate banners) are invisible from reading code alone.
Ask your AI coding assistant with this prompt:
Flutter Campus Notification App.
Stack: firebase_messaging, flutter_local_notifications,
flutter_secure_storage, go_router, Riverpod.
Generate a PushService with:
- requestPermission + getToken + onTokenRefresh (send to POST /devices)
- onMessage (show a local notification manually)
- onMessageOpenedApp + getInitialMessage (navigate to data.route)
- subscribe/unsubscribe topic campus-announcement
- top-level background handler with @pragma('vm:entry-point')
Mark which parts DIFFER for Android 13+ vs iOS,
and which parts must never touch BuildContext.
Before accepting the AI draft, verify and record your findings in the README/docs:
@pragma('vm:entry-point')? (reject it if it is a class method).onTokenRefresh actually send the new token to the backend, not just print it to the log?/login, /announcement/:id) into one lib/routes.dart file so FCM deep links and GoRouter share the same constants.RemoteMessage -> route parsing into a pure function routeFromMessage(Map<String, dynamic> data) so it can be unit-tested without Firebase.DioException -> user-friendly message mapping (401, timeout, offline) into lib/data/api_errors.dart so the UI only receives messages, never raw exceptions.Create test/auth_push_test.dart. Firebase itself is not tested; the logic around it is:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
String routeFromMessage(Map<String, String> data) {
final route = data['route'] ?? '/';
return route.startsWith('/') ? route : '/$route';
}
class FakeTokenStore {
String? access;
String? refresh;
}
void main() {
test('routeFromMessage handles empty and slash-less routes', () {
expect(routeFromMessage({}), '/');
expect(routeFromMessage({'route': 'announcement/3'}), '/announcement/3');
expect(routeFromMessage({'route': '/announcement/3'}), '/announcement/3');
});
test('data payload carries the announcement id', () {
const data = {'route': '/announcement/3', 'id': '3'};
expect(data['id'], '3');
expect(routeFromMessage(data), '/announcement/3');
});
test('auth provider reads login status from token', () async {
final store = FakeTokenStore()..access = 'mock-access';
expect(store.access != null, isTrue);
store.access = null;
expect(store.access != null, isFalse);
});
test('failed refresh -> session cleared (force re-login)', () async {
final store = FakeTokenStore()..refresh = '';
final needsLogin = (store.refresh ?? '').isEmpty;
expect(needsLogin, isTrue);
});
}
Run:
flutter analyze
flutter test
| Symptom | Common cause | Fix |
|---|---|---|
| Null token on emulator | Emulator without Google Play Services | Use a Play Store emulator image or a physical device |
| No banner in foreground | Relying on the automatic system banner | Show one manually via flutter_local_notifications in onMessage |
| Tap does nothing (terminated) | getInitialMessage never called at startup | Call handleTerminated after the router is ready, forwarding data.route |
| Repeated 401 after login | Refresh interceptor never replays the request / refresh also expired | Replay once after refresh; on failure clear() and route to /login |
MissingPluginException for secure storage / messaging | Hot reload after adding a plugin | Stop fully, then flutter run again |
| No notifications on iOS | Missing APNs key / push capability | Configure APNs in the Firebase Console + enable Push in Xcode |
flutter_secure_storage, never in SharedPreferences/logs/full screenshots.flutter analyze is clean and all tests pass.Build a Campus Notification App (extend the codelab project or start fresh):
/login.getToken + onTokenRefresh delivered to the backend (or a documented POST /devices endpoint), plus a campus-announcement topic subscription.notification + data messages; taps open /announcement/:id in all three app states. Fill in the foreground/background/terminated test table in the README.screenshots/.docs/.06-week-6-authentication-security-fcm/ with lib/, test/, docs/, README.md, and screenshots/. The README explains the objective, main features, tech stack, how to run, and the result achieved.onTokenRefresh is ignored for a whole semester?