Authentication & Security

Security Hardening: Network, Data & Auth Best Practices

16 min Lesson 12 of 12

Security Hardening: Network, Data & Auth Best Practices

Throughout this tutorial you have built a complete authentication system. This final lesson consolidates everything into a defence-in-depth checklist — layering multiple independent controls so that if one safeguard fails, the others still protect your users. We will cover HTTPS enforcement, secure field hygiene, proper logout with token revocation, and auditing Firebase Security Rules.

1. Enforcing HTTPS-Only Connections

Sending credentials or tokens over plain HTTP exposes them to man-in-the-middle attacks. Flutter's HTTP clients do not enforce HTTPS by default, so you must add explicit checks.

Custom HTTP Client That Rejects Plain HTTP

import 'package:http/http.dart' as http;

class SecureHttpClient extends http.BaseClient {
  final http.Client _inner = http.Client();

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    if (request.url.scheme != 'https') {
      throw StateError(
        'Insecure request blocked: ${request.url}. '
        'All requests must use HTTPS.',
      );
    }
    return _inner.send(request);
  }

  @override
  void close() => _inner.close();
}

// Usage — register once at app startup
final secureClient = SecureHttpClient();

Future<Map<String, dynamic>> fetchUserProfile(String token) async {
  final response = await secureClient.get(
    Uri.parse('https://api.example.com/profile'),
    headers: {'Authorization': 'Bearer $token'},
  );
  if (response.statusCode == 200) {
    return jsonDecode(response.body) as Map<String, dynamic>;
  }
  throw Exception('Failed to load profile: ${response.statusCode}');
}
Tip: When using the dio package, add a custom Interceptor that checks options.uri.scheme == 'https' before every request and throws a DioException otherwise. Centralising this in an interceptor means every call site is protected automatically.

2. Clearing Clipboard & Secure Field Hygiene

Password fields, OTP codes, and secret keys are often copied to the clipboard by users or auto-filled by accessibility services. Leaving sensitive data in the clipboard after a session ends is a low-effort attack vector.

Clearing the Clipboard on Logout and Routing

import 'package:flutter/services.dart';

/// Call this whenever the user logs out or the auth screen is disposed.
Future<void> clearSensitiveClipboard() async {
  // Replace clipboard contents with an empty string.
  await Clipboard.setData(const ClipboardData(text: ''));
}

// In a password field widget — clear on dispose
class _SecurePasswordFieldState extends State<SecurePasswordField> {
  final TextEditingController _controller = TextEditingController();

  @override
  void dispose() {
    _controller.clear();   // wipe the controller buffer
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      obscureText: true,
      // enableSuggestions and autocorrect must both be false
      // to prevent the keyboard from caching the typed text.
      enableSuggestions: false,
      autocorrect: false,
      keyboardType: TextInputType.visiblePassword,
      decoration: const InputDecoration(labelText: 'Password'),
    );
  }
}
Warning: Setting obscureText: true alone is not sufficient. On Android, some keyboards still log obscured keystrokes. Always pair it with enableSuggestions: false and autocorrect: false. On iOS, keyboard cache is suppressed automatically for UITextContentTypePassword, which Flutter maps to TextInputType.visiblePassword.

3. Proper Logout with Token Revocation

Simply navigating away from the home screen is not a logout. A real logout must: revoke server-side tokens, clear local storage, wipe in-memory state, and clear the navigation stack so the back button cannot return to authenticated screens.

Complete Logout Flow

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  final FlutterSecureStorage _storage = const FlutterSecureStorage();

  /// Full logout: revoke tokens, wipe storage, clear memory.
  Future<void> logout(BuildContext context) async {
    try {
      // 1. Revoke the Firebase ID token refresh token on the server side.
      //    signOut() marks the current credential invalid; any cached
      //    ID token can no longer be refreshed after this call.
      await _auth.signOut();

      // 2. If you issued a custom backend JWT, revoke it explicitly.
      await _revokeBackendToken();

      // 3. Wipe all locally persisted sensitive data.
      await _storage.deleteAll();

      // 4. Clear the clipboard in case credentials were copied.
      await Clipboard.setData(const ClipboardData(text: ''));

    } catch (e) {
      // Log but do not rethrow — always complete local cleanup.
      debugPrint('Logout error: $e');
    } finally {
      // 5. Navigate to login and remove ALL routes from the stack.
      if (context.mounted) {
        Navigator.of(context).pushNamedAndRemoveUntil(
          '/login',
          (route) => false,   // removes every previous route
        );
      }
    }
  }

  Future<void> _revokeBackendToken() async {
    final token = await _storage.read(key: 'refresh_token');
    if (token == null) return;
    await secureClient.post(
      Uri.parse('https://api.example.com/auth/revoke'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'refresh_token': token}),
    );
  }
}
Note: pushNamedAndRemoveUntil with the predicate (route) => false clears the entire back-stack. Without this, pressing the Android back button from the login screen would return the user to the authenticated area — a well-known security oversight in mobile apps.

4. Auditing Firebase Security Rules

Firebase Security Rules are your last line of defence on the server side. Misconfigured rules — for example, allow read, write: if true; — expose every document to the entire internet. A security audit should cover four areas: authentication checks, ownership enforcement, data validation, and least-privilege scoping.

Hardened Firestore Security Rules Template

// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // --- Helper functions ---
    function isAuthenticated() {
      return request.auth != null;
    }
    function isOwner(userId) {
      return isAuthenticated() && request.auth.uid == userId;
    }
    function isEmailVerified() {
      return isAuthenticated() && request.auth.token.email_verified == true;
    }
    function validUserWrite() {
      // Only allow writing known fields; reject extra fields.
      return request.resource.data.keys().hasOnly([
        'displayName', 'avatarUrl', 'updatedAt'
      ]);
    }

    // --- User profiles ---
    match /users/{userId} {
      // Anyone authenticated can read a profile.
      allow read: if isAuthenticated();
      // Only the owner can write; email must be verified; fields validated.
      allow write: if isOwner(userId)
                   && isEmailVerified()
                   && validUserWrite();
    }

    // --- Private user data ---
    match /users/{userId}/private/{doc} {
      // Only the owner can read or write their own private sub-collection.
      allow read, write: if isOwner(userId);
    }

    // --- Deny everything else by default ---
    match /{document=**} {
      allow read, write: if false;
    }
  }
}
Tip: Use the Firebase Emulator Suite to run automated rule tests with the @firebase/rules-unit-testing package before deploying. Write tests that assert both allowed and denied cases — a rule that only passes the "allowed" tests may still be dangerously over-permissive.

5. Additional Hardening Checklist

Beyond the four main areas above, apply these complementary controls before releasing to production:

  • Certificate pinning — embed your server's public-key hash in the app and reject certificates that do not match, preventing rogue certificate authority abuse.
  • Root/jailbreak detection — use flutter_jailbreak_detection to warn users or restrict functionality on compromised devices.
  • Obfuscate release builds — run flutter build apk --obfuscate --split-debug-info=build/debug-info to make reverse-engineering significantly harder.
  • Avoid storing secrets in code — API keys in Dart source are easily extracted from the compiled binary; use backend proxy endpoints or Firebase Remote Config with server-side access rules instead.
  • Short token lifetimes — keep ID tokens short-lived (Firebase default: 1 hour) and use refresh tokens only over HTTPS with PKCE when applicable.
  • Audit third-party packages — run flutter pub audit regularly and pin critical package versions in pubspec.lock.

Summary

Defence-in-depth means no single control is treated as sufficient. By combining HTTPS enforcement at the network layer, secure field hygiene at the input layer, token revocation and navigation stack clearing at the session layer, and tight Firebase Security Rules at the data layer, your authentication system gains meaningful resilience against the most common mobile attack vectors. Apply the checklist above before every production release and revisit it whenever you add new features that touch user credentials.