Lesson 45 of 77 advanced

Mobile Security: JWT, Secure Storage, Biometrics & Encryption

JWT flow, flutter_secure_storage, biometric auth, certificate pinning, OWASP mobile top 10

Open interactive version (quiz + challenge)

Real-world analogy

Think of mobile app security as a bank vault. JWT tokens are the time-limited access badges (they expire). Secure storage is the vault itself (not under the mattress). Biometrics are the fingerprint scanner at the door. Certificate pinning is checking that the bank building is actually the real bank and not a replica. OWASP mobile top 10 is the checklist the security auditors use when they inspect the vault.

What is it?

Mobile security protects user data, session integrity, and app authenticity. For fintech, healthcare, and any app handling personal data, security vulnerabilities can result in regulatory fines, data breaches, and loss of user trust. Senior Flutter engineers must be able to implement and articulate the full security stack.

Real-world relevance

In a fintech claims/refunds app: JWT access tokens stored in flutter_secure_storage (never SharedPreferences). Dio interceptor auto-refreshes tokens on 401. Biometric gate prevents shoulder-surfing on the claims list. Certificate pinning protects BankID communication. R8 obfuscation and --obfuscate in release builds. Security audit uses OWASP Mobile Top 10 as the checklist.

Key points

Code example

// === JWT TOKEN REFRESH WITH DIO INTERCEPTOR ===
class AuthInterceptor extends Interceptor {
  final FlutterSecureStorage _storage = const FlutterSecureStorage();
  bool _isRefreshing = false;
  final _pendingRequests = <ErrorInterceptorHandler>[];

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401 && !_isRefreshing) {
      _isRefreshing = true;
      try {
        final refreshToken = await _storage.read(key: 'refresh_token');
        if (refreshToken == null) { _logout(); return; }

        final response = await _dio.post('/auth/refresh',
            data: {'refresh_token': refreshToken});

        final newAccessToken = response.data['access_token'];
        await _storage.write(key: 'access_token', value: newAccessToken);

        // Retry failed request with new token
        err.requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
        final retried = await _dio.fetch(err.requestOptions);
        handler.resolve(retried);
      } catch (e) {
        _logout();
        handler.reject(err);
      } finally {
        _isRefreshing = false;
      }
    } else {
      handler.next(err);
    }
  }
}

// === SECURE TOKEN STORAGE ===
class TokenRepository {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  Future<void> saveTokens({
    required String access, required String refresh,
  }) async {
    await Future.wait([
      _storage.write(key: 'access_token', value: access),
      _storage.write(key: 'refresh_token', value: refresh),
    ]);
  }

  Future<bool> isTokenExpired() async {
    final token = await _storage.read(key: 'access_token');
    if (token == null) return true;
    // Decode JWT payload (base64url) — no signature validation client-side
    final parts = token.split('.');
    final payload = json.decode(
      utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))
    );
    final exp = payload['exp'] as int;
    return DateTime.now().millisecondsSinceEpoch / 1000 > exp;
  }
}

// === BIOMETRIC AUTHENTICATION ===
class BiometricService {
  final _localAuth = LocalAuthentication();

  Future<bool> authenticate() async {
    final canAuth = await _localAuth.canCheckBiometrics;
    if (!canAuth) return false;
    return _localAuth.authenticate(
      localizedReason: 'Verify your identity to view claims',
      options: const AuthenticationOptions(
        biometricOnly: false,  // Allow PIN fallback
        stickyAuth: true,      // Don't cancel on background
      ),
    );
  }
}

// === CERTIFICATE PINNING WITH CUSTOM HTTP CLIENT ===
HttpClient buildPinnedClient() {
  final context = SecurityContext(withTrustedRoots: false);
  // Only trust our server's certificate
  context.setTrustedCertificatesBytes(
    (await rootBundle.load('assets/certs/server.pem')).buffer.asUint8List(),
  );
  return HttpClient(context: context);
}

// === BUILD-TIME SECRET INJECTION ===
// flutter build apk --dart-define=API_KEY=prod_secret_here
const apiKey = String.fromEnvironment('API_KEY', defaultValue: '');

Line-by-line walkthrough

  1. 1. AuthInterceptor.onError fires on every Dio error — checks for 401 before doing anything else
  2. 2. _isRefreshing flag prevents concurrent refresh calls when multiple requests fail simultaneously
  3. 3. storage.read('refresh_token') fetches from Android Keystore / iOS Keychain — hardware-backed encrypted storage
  4. 4. err.requestOptions.headers['Authorization'] = newToken — mutates the original request before retrying
  5. 5. FlutterSecureStorage AndroidOptions(encryptedSharedPreferences: true) uses EncryptedSharedPreferences — AES-256 backed by Keystore
  6. 6. parts[1] is the JWT payload segment — base64url decode it to read expiry without needing the signing key
  7. 7. local_auth biometricOnly: false allows PIN fallback — critical for accessibility and when biometrics fail
  8. 8. SecurityContext(withTrustedRoots: false) rejects ALL system CAs — only our pinned certificate is trusted

Spot the bug

// Token storage in onboarding flow
class OnboardingService {
  Future<void> saveUserSession(String token, String userId) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('access_token', token);
    await prefs.setString('user_id', userId);
    print('Session saved for user: $userId token: $token');
  }
}
Need a hint?
This code has two OWASP violations. Identify both.
Show answer
Bug 1 (M9 - Insecure Data Storage): SharedPreferences stores data as unencrypted XML on the filesystem. On rooted devices or via ADB backup, any app can read this file. The access token must be stored in flutter_secure_storage which uses Android Keystore / iOS Keychain. Bug 2 (M1 - Improper Credential Usage / M6 - Inadequate Privacy Controls): print() writes the token and userId to the system log (logcat/Console). Device logs are accessible to other apps with READ_LOGS permission and anyone with USB debugging access. Never log tokens, passwords, or PII. Fix: replace SharedPreferences with FlutterSecureStorage and remove the print statement.

Explain like I'm 5

Imagine your app is a secret club. JWT tokens are VIP wristbands that expire at midnight. Secure storage is a real safe, not a drawer (SharedPreferences is a drawer anyone can open). Biometrics is the face scanner at the door — but the wristband still came from the club, the scanner just checks you're the right person. Certificate pinning is checking the club's official stamp before you hand over your wristband — to make sure you're at the real club.

Fun fact

The OWASP Mobile Top 10 was first published in 2016. M9 (Insecure Data Storage) has been in the top 3 every year since. In 2022, a security researcher found that a major banking app was storing JWT access tokens in SharedPreferences — readable by any app with root access or USB debugging enabled. flutter_secure_storage exists specifically to prevent this.

Hands-on challenge

Design the complete authentication security stack for a fintech BankID claims app: (1) describe the full JWT flow from login to auto-refresh to logout, (2) where each token is stored and why, (3) how biometrics fit in without replacing server auth, (4) one OWASP item that is most likely to be violated in a rushed implementation.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery