← Back to posts Cover image for Flutter Authentication: JWT vs. Session Cookies for Mobile & Web Backends

Flutter Authentication: JWT vs. Session Cookies for Mobile & Web Backends

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

When building a Flutter app that shares a backend with a web application, you face a classic architectural decision: how should your app authenticate users? The web side likely uses traditional session cookies, but mobile introduces different constraints. Do you retrofit cookie handling into your Flutter app, or switch the entire system to use JSON Web Tokens (JWT)? Let’s break down the practical differences and how to implement each approach in Flutter.

The Core Difference: Stateful vs. Stateless

Session Cookies are a stateful mechanism. When a user logs in, the server creates a session record (often in a database or cache) and sends its ID back as a cookie. Every subsequent request from the client includes this cookie. The server must look up the session ID to validate the request. This is straightforward for web browsers, which automatically handle cookie storage and transmission.

JWT (JSON Web Tokens) are stateless. The server generates a signed token containing user claims (like user ID and permissions) and sends it to the client. The client stores this token (often in secure_store for Flutter) and sends it back, typically in an Authorization header. The server can validate the token’s signature without querying a database, making it highly scalable.

Why This Matters for Flutter

Flutter apps don’t behave like browsers. They don’t automatically send or receive cookies with HTTP requests unless you explicitly configure them to do so. This makes JWT’s header-based approach feel more native to mobile development. However, if your backend already uses sessions for the web, you might want to maintain a single auth system.

To use your existing session backend, you need to manually handle cookies. The http package can manage a cookie jar, but the dio package makes it simpler.

First, add dio and dio_cookie_manager to your pubspec.yaml:

dependencies:
  dio: ^5.4.0
  dio_cookie_manager: ^2.0.0
  cookie_jar: ^2.0.0

Here’s a basic setup for a login flow that preserves cookies:

import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:cookie_jar/cookie_jar.dart';

class SessionAuthClient {
  late Dio dio;
  late CookieJar cookieJar;

  SessionAuthClient() {
    cookieJar = CookieJar();
    dio = Dio(BaseOptions(baseUrl: 'https://your-api.com'));
    // Add the cookie manager to interceptors
    dio.interceptors.add(CookieManager(cookieJar));
  }

  Future<bool> login(String email, String password) async {
    try {
      final response = await dio.post(
        '/login',
        data: {'email': email, 'password': password},
      );
      // Cookies are automatically saved by CookieManager
      return response.statusCode == 200;
    } on DioException catch (e) {
      print('Login failed: ${e.message}');
      return false;
    }
  }

  Future<Response> getProtectedData() async {
    // The saved cookies are automatically attached to this request
    return await dio.get('/user/profile');
  }
}

The key is the CookieManager interceptor, which automatically stores cookies from responses and attaches them to subsequent requests, mimicking a browser. You must persist the cookie jar across app restarts, which requires a persistent storage location like path_provider.

Implementing JWT Authentication in Flutter

With JWT, you explicitly manage the token. You’ll typically store it securely and add it to request headers.

Add dio and flutter_secure_storage:

dependencies:
  dio: ^5.4.0
  flutter_secure_storage: ^9.0.0

Here’s a typical JWT client implementation:

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

class JWTAuthClient {
  late Dio dio;
  final FlutterSecureStorage _storage = const FlutterSecureStorage();
  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';

  JWTAuthClient() {
    dio = Dio(BaseOptions(baseUrl: 'https://your-api.com'));
    // Add an interceptor to attach the token
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        final token = await _storage.read(key: _accessTokenKey);
        if (token != null) {
          options.headers['Authorization'] = 'Bearer $token';
        }
        handler.next(options);
      },
      onError: (error, handler) async {
        // Implement token refresh on 401 errors
        if (error.response?.statusCode == 401) {
          final refreshed = await _refreshToken();
          if (refreshed) {
            // Retry the original request
            return handler.resolve(await dio.fetch(error.requestOptions));
          }
        }
        handler.next(error);
      },
    ));
  }

  Future<bool> login(String email, String password) async {
    try {
      final response = await dio.post(
        '/auth/login',
        data: {'email': email, 'password': password},
      );
      await _storage.write(
          key: _accessTokenKey, value: response.data['access_token']);
      await _storage.write(
          key: _refreshTokenKey, value: response.data['refresh_token']);
      return true;
    } on DioException {
      return false;
    }
  }

  Future<bool> _refreshToken() async {
    try {
      final refreshToken = await _storage.read(key: _refreshTokenKey);
      final response = await dio.post('/auth/refresh', data: {
        'refresh_token': refreshToken,
      });
      await _storage.write(
          key: _accessTokenKey, value: response.data['access_token']);
      return true;
    } catch (e) {
      // Refresh failed, user must log in again
      await logout();
      return false;
    }
  }

  Future<void> logout() async {
    await _storage.delete(key: _accessTokenKey);
    await _storage.delete(key: _refreshTokenKey);
  }
}

Which Should You Choose? Practical Guidance

  • Stick with Sessions if: Your backend is already built around sessions, and you want minimal backend changes. The Flutter implementation is manageable with dio_cookie_manager. Be mindful of cross-origin issues if your API and Flutter app use different domains.
  • Choose JWT if: You’re building a new system or can modify the backend. JWTs are excellent for scalability (no session store to query) and work seamlessly with native mobile patterns. They also simplify scenarios where your Flutter app talks to multiple backend services, as each service can independently validate the token.
  • The Hybrid Approach: Some backends support both. The web app uses sessions, while the mobile API accepts a JWT sent in an Authorization header. This keeps both clients happy but adds complexity on the server.

Common Mistakes to Avoid

  1. Storing JWTs insecurely: Never store a JWT in shared_preferences or as a plain file. Always use flutter_secure_storage for tokens.
  2. Forgetting to persist cookies: The default CookieJar is in-memory. Use a PersistCookieJar with path_provider to save cookies across app restarts.
  3. Ignoring token expiration: Always implement a token refresh flow for JWTs. The interceptor pattern shown above is a robust way to handle 401 errors silently.
  4. Not securing your backend: Regardless of your choice, always use HTTPS. For sessions, ensure cookies are marked Secure and HttpOnly. For JWTs, keep them short-lived and use strong signing algorithms.

Ultimately, both methods are secure when implemented correctly. Your decision should hinge on your backend’s existing architecture, your team’s expertise, and the long-term scalability requirements of your application. Flutter is flexible enough to handle either pattern effectively.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.

Cover image for Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.