One of the first hurdles every Flutter developer faces is Authentication. It's not just about sending a username and password to a server; it's about managing the "state" of the user.
- How do I update the UI when the login fails?
- How do I keep the user logged in if they restart the app?
- Where do I store the token securely?
In this guide, we are going to build a robust authentication flow using Provider for state management and Flutter Secure Storage for persistence.
The Tech Stack
- Provider: To separate our logic from our UI.
- HTTP: To communicate with our API.
- Flutter Secure Storage: To store the JWT token safely (never use SharedPreferences for sensitive data!).
Step 1: The Blueprint (Model)
First, we need to define what our data looks like. When we send our credentials to the server, we expect a response containing an authentication token.
Here is our LoginModel. It acts as a translator, converting the raw JSON response from the server into a Dart object we can actually use.
// auth_model.dart
import 'dart:convert';
LoginModel loginModelFromJson(String str) => LoginModel.fromJson(json.decode(str));
class LoginModel {
final String token;
LoginModel({required this.token});
factory LoginModel.fromJson(Map<String, dynamic> json) => LoginModel(
token: json["token"],
);
}Step 2: Talking to the Server (Service)
We want to keep our code clean, so we separate the API calls into their own file. The AuthService doesn't know about the UI; its only job is to knock on the server's door and ask for a token.
We are posting the user's credentials to the fakestoreapi endpoint. If the server replies with a 201 (Created/Success), we parse the data and return the token.
// auth_service.dart
import 'package:http/http.dart' as http;
import 'package:login_using_mock/model/auth_model.dart';
class AuthService {
static Future<String?> callLogin({
required String username,
required String password,
}) async {
final String url = 'https://fakestoreapi.com/auth/profile';
var data = {"username": username, "password": password};
final response = await http.post(Uri.parse(url), body: data);
if (response.statusCode == 201) {
final body = loginModelFromJson(response.body);
return body.token;
}
return null;
}
}Step 3: The Brain of the App (Provider)
This is where the magic happens. The AuthProvider manages the state for the entire application. It handles three critical tasks:
- Login: Calls the service and updates the UI state (loading vs. not loading).
- Persistence: Saves the token to secure storage so the user stays logged in.
- Initialization: Checks for an existing token when the app starts.
Pro Tip: Notice the
init()method. We call this immediately when the app launches to check if the user has been here before.
Dart
// auth_provider.dart
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:login_using_mock/core/auth_service.dart';
class AuthProvider with ChangeNotifier {
bool isLoading = false;
String? token;
bool isInitialized = false;
final storage = FlutterSecureStorage();
bool get isAuthenicated => token != null;
// Check for existing token on app start
Future<void> init() async {
final storedToken = await storage.read(key: 'auth_token');
if (storedToken != null) {
token = storedToken;
}
isInitialized = true;
notifyListeners();
}
// Handle Login Logic
Future<bool> login({required String username, required String password}) async {
isLoading = true;
notifyListeners();
final result = await AuthService.callLogin(username: username, password: password);
isLoading = false;
if (result == null) {
notifyListeners();
return false;
}
// Save token securely
token = result;
await storage.write(key: 'auth_token', value: token);
notifyListeners();
return true;
}
// Handle Logout
Future<void> logout() async {
await storage.delete(key: 'auth_token');
token = null;
notifyListeners();
}
}Step 4: Connecting the UI
We don't want to manually push and pop routes (e.g., Navigator.push). Instead, we want our app to be reactive.
In our main.dart, we listen to the AuthProvider. Based on the current state, the app decides which screen to show:
- Splash Screen: While we are checking secure storage.
- Home Screen: If the user has a token.
- Login Screen: If the user is unauthenticated.
// main.dart
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => AuthProvider()..init(), // Initialize immediately
child: MainApp(),
),
);
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Consumer<AuthProvider>(
builder: (context, authProvider, _) {
// 1. Waiting for storage check
if (!authProvider.isInitialized) {
return SplashScreen();
}
// 2. Decide where to go
return authProvider.isAuthenicated ? HomeScreen() : LoginScreen();
},
),
);
}
}Step 5: The Login Screen
Finally, the LoginScreen is simple. It collects the input and calls authProvider.login(). By using await, we can determine if the login failed and show a SnackBar immediately.
Dart
// login_screen.dart logic
ElevatedButton(
onPressed: () async {
// Trigger the provider
bool success = await authProvider.login(
username: _userNameController.text,
password: _passwordController.text,
);
// Provide feedback
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login failed'))
);
}
},
child: Text('Login'),
)Summary
By structuring our app this way, we have achieved a few major wins:
- Security: Tokens are stored in
FlutterSecureStorage, not plain text. - User Experience: Users don't have to log in every time they open the app.
- Clean Code: The UI layer doesn't know how login works; it just asks the Provider to do it.