Last updated: September 27, 2026

Bahasa Indonesia | English

Learning Objectives

After completing this codelab, students will be able to:

Prerequisites

1. Authentication models used in this codelab

Three common industry login patterns:

PatternHow it works (brief)When to use it
Firebase Auth (email/Google)The Firebase SDK exchanges credentials for a JWT ID token verified by the backendCampus 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 tokenYour own campus backend
OAuth (Google Login)The app receives an authorization code, exchanged for tokens via the providerSocial 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.

2. Tokens: access, refresh, and ID token

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

3. FCM architecture

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.

4. Notification payload vs data payload

TypeContentSystem behavior
notificationDisplay title + bodyAndroid shows it automatically in background/terminated; foreground needs manual display
dataFree 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.

5. Three app states you must test

StateMeaningWorking handler
ForegroundApp is openFirebaseMessaging.onMessage (display manually via local notification)
BackgroundApp is minimizedAutomatic system banner + onMessageOpenedApp on click
TerminatedApp is killedgetInitialMessage() when opened from the notification

Set up the project

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

1. Secure token storage

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();
}

2. Auth repository (mock, ready to swap for Firebase Auth)

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}';
  }
}

3. Dio with automatic refresh

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;
}

4. Auth provider + route guard

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'] ?? ''),
    ),
  ],
);

1. Register the app with Firebase

  1. Create a project in the Firebase Console and add an Android app with the package name matching your applicationId.
  2. Download 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.
  3. Make sure firebase_core is initialized before runApp: await Firebase.initializeApp().

2. Request notification permission

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;

3. Token lifecycle: fetch, send to backend, watch for rotation

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'});
});

4. First send test from the Firebase Console

  1. Open Firebase Console -> Messaging -> create a trial notification campaign.
  2. Enter a title and body, targeting your Android app.
  3. Send while the app is in the background: the system banner must appear. Tap the banner: the app opens.
  4. Record the result as screenshots/fcm-console-test.png.

1. Background handler must be top-level

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);
}

2. Three handlers + combined payload example

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!);
}

3. Mandatory test matrix

Test all three states with the same payload and fill in this table in the README:

StateExpectedHow to test
ForegroundLocal banner appears, tap routes to /announcement/3App open, send from console/backend
BackgroundSystem banner appears, tap routes correctlyPress Home, send, tap banner
TerminatedApp opens to the right route via getInitialMessageSwipe-close the app, send, tap banner

4. Topic messaging

// 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.

The role of AI in this codelab

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.

AI Prompt Challenge

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.

AI Verification Checklist

Before accepting the AI draft, verify and record your findings in the README/docs:

Refactoring Challenge

  1. Move all route strings (/login, /announcement/:id) into one lib/routes.dart file so FCM deep links and GoRouter share the same constants.
  2. Extract RemoteMessage -> route parsing into a pure function routeFromMessage(Map<String, dynamic> data) so it can be unit-tested without Firebase.
  3. Move DioException -> user-friendly message mapping (401, timeout, offline) into lib/data/api_errors.dart so the UI only receives messages, never raw exceptions.

Testing: unit tests without real Firebase

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

Common errors and fixes

SymptomCommon causeFix
Null token on emulatorEmulator without Google Play ServicesUse a Play Store emulator image or a physical device
No banner in foregroundRelying on the automatic system bannerShow one manually via flutter_local_notifications in onMessage
Tap does nothing (terminated)getInitialMessage never called at startupCall handleTerminated after the router is ready, forwarding data.route
Repeated 401 after loginRefresh interceptor never replays the request / refresh also expiredReplay once after refresh; on failure clear() and route to /login
MissingPluginException for secure storage / messagingHot reload after adding a pluginStop fully, then flutter run again
No notifications on iOSMissing APNs key / push capabilityConfigure APNs in the Firebase Console + enable Push in Xcode

Self-verification checklist

Mini project / Industry Challenge

Build a Campus Notification App (extend the codelab project or start fresh):

  1. Login (mock/Firebase Auth) with a route guard: unauthenticated users are always redirected to /login.
  2. Tokens in secure storage; Dio auto-refreshes once on 401 and logs out when the refresh dies.
  3. FCM integrated: permission, getToken + onTokenRefresh delivered to the backend (or a documented POST /devices endpoint), plus a campus-announcement topic subscription.
  4. Combined notification + data messages; taps open /announcement/:id in all three app states. Fill in the foreground/background/terminated test table in the README.
  5. Screenshot evidence (truncated token, banner per state, deep-link destination page) under screenshots/.
  6. At least 2 passing tests (route parsing + session/refresh logic).
  7. Complete the AI Challenge and document the prompt, initial AI output, manual fixes, and technical rationale in docs/.
  8. Push to the portfolio repository under 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.

Reflection

Supporting references