
User authentication is one of the most critical flows in any app. Whether you’re building a social media platform, an e-commerce app, or a chat tool, a clean and responsive login/register experience can make or break your app’s first impression. Integrating a login register flow in Flutter using Riverpod can streamline this process, providing robust state management.
In this comprehensive guide, you’ll learn how to build a complete login and register flow in Flutter using Riverpod, with form validation, secure token storage, automatic session persistence, protected routes, and proper error handling. We’ll build everything in a scalable and testable way — perfect for production-ready apps.
Why Choose Riverpod for Authentication?
Riverpod is more than just a replacement for Provider — it’s a complete rethink of how to handle state in Flutter. For authentication, Riverpod provides several benefits:
- No BuildContext needed to access providers — check auth state from anywhere
- Testable by default — mock providers easily in unit tests
- Robust async handling with AsyncValue for loading, error, and data states
- Automatic disposal — providers clean up when no longer needed
- Type-safe — compile-time errors instead of runtime crashes
Packages Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
flutter_hooks: ^0.20.5
hooks_riverpod: ^2.4.9
dio: ^5.4.0 # HTTP client
flutter_secure_storage: ^9.0.0 # Secure token storage
go_router: ^13.0.0 # Navigation
freezed_annotation: ^2.4.1 # Immutable classes
dev_dependencies:
build_runner: ^2.4.7
riverpod_generator: ^2.3.9
freezed: ^2.4.6
json_serializable: ^6.7.1
Project Structure
lib/
├── main.dart
├── app.dart
├── core/
│ ├── constants/
│ │ └── api_constants.dart
│ ├── network/
│ │ └── dio_client.dart
│ ├── storage/
│ │ └── secure_storage.dart
│ └── router/
│ └── app_router.dart
└── features/
└── auth/
├── data/
│ ├── models/
│ │ ├── user_model.dart
│ │ └── auth_response.dart
│ └── repositories/
│ └── auth_repository.dart
├── domain/
│ └── auth_state.dart
├── presentation/
│ ├── controllers/
│ │ └── auth_controller.dart
│ ├── pages/
│ │ ├── login_page.dart
│ │ ├── register_page.dart
│ │ └── splash_page.dart
│ └── widgets/
│ ├── auth_form.dart
│ └── social_login_buttons.dart
└── providers/
└── auth_providers.dart
Data Models
// data/models/user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String email,
String? name,
String? avatarUrl,
@Default(false) bool emailVerified,
DateTime? createdAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// data/models/auth_response.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_response.freezed.dart';
part 'auth_response.g.dart';
@freezed
class AuthResponse with _$AuthResponse {
const factory AuthResponse({
required String accessToken,
required String refreshToken,
required User user,
@Default(3600) int expiresIn,
}) = _AuthResponse;
factory AuthResponse.fromJson(Map<String, dynamic> json) =>
_$AuthResponseFromJson(json);
}
// domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../data/models/user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
Secure Storage Service
// core/storage/secure_storage.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final secureStorageProvider = Provider<SecureStorageService>((ref) {
return SecureStorageService();
});
class SecureStorageService {
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
static const _userKey = 'user_data';
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
// Access Token
Future<void> saveAccessToken(String token) async {
await _storage.write(key: _accessTokenKey, value: token);
}
Future<String?> getAccessToken() async {
return await _storage.read(key: _accessTokenKey);
}
// Refresh Token
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: _refreshTokenKey, value: token);
}
Future<String?> getRefreshToken() async {
return await _storage.read(key: _refreshTokenKey);
}
// User Data
Future<void> saveUserData(String userData) async {
await _storage.write(key: _userKey, value: userData);
}
Future<String?> getUserData() async {
return await _storage.read(key: _userKey);
}
// Clear all auth data
Future<void> clearAuthData() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
await _storage.delete(key: _userKey);
}
// Check if user is logged in
Future<bool> hasValidSession() async {
final token = await getAccessToken();
return token != null && token.isNotEmpty;
}
}
Dio HTTP Client with Interceptors
// core/network/dio_client.dart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../storage/secure_storage.dart';
final dioProvider = Provider<Dio>((ref) {
final storage = ref.read(secureStorageProvider);
return DioClient(storage).dio;
});
class DioClient {
final SecureStorageService _storage;
late final Dio dio;
DioClient(this._storage) {
dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
dio.interceptors.addAll([
_AuthInterceptor(_storage, dio),
_LoggingInterceptor(),
]);
}
}
class _AuthInterceptor extends Interceptor {
final SecureStorageService _storage;
final Dio _dio;
_AuthInterceptor(this._storage, this._dio);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// Skip auth header for login/register endpoints
if (options.path.contains('/auth/login') ||
options.path.contains('/auth/register')) {
return handler.next(options);
}
final token = await _storage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
// Try to refresh token
final refreshed = await _refreshToken();
if (refreshed) {
// Retry the request
final retryResponse = await _retry(err.requestOptions);
return handler.resolve(retryResponse);
}
}
handler.next(err);
}
Future<bool> _refreshToken() async {
try {
final refreshToken = await _storage.getRefreshToken();
if (refreshToken == null) return false;
final response = await _dio.post(
'/auth/refresh',
data: {'refresh_token': refreshToken},
);
if (response.statusCode == 200) {
await _storage.saveAccessToken(response.data['access_token']);
await _storage.saveRefreshToken(response.data['refresh_token']);
return true;
}
} catch (e) {
await _storage.clearAuthData();
}
return false;
}
Future<Response> _retry(RequestOptions requestOptions) async {
final token = await _storage.getAccessToken();
final options = Options(
method: requestOptions.method,
headers: {...requestOptions.headers, 'Authorization': 'Bearer $token'},
);
return _dio.request(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
);
}
}
class _LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
print('REQUEST[${options.method}] => PATH: ${options.path}');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
print('RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
print('ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}');
handler.next(err);
}
}
Auth Repository
// data/repositories/auth_repository.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/dio_client.dart';
import '../../core/storage/secure_storage.dart';
import '../models/auth_response.dart';
import '../models/user_model.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(
ref.read(dioProvider),
ref.read(secureStorageProvider),
);
});
class AuthRepository {
final Dio _dio;
final SecureStorageService _storage;
AuthRepository(this._dio, this._storage);
/// Login with email and password
Future<User> login(String email, String password) async {
try {
final response = await _dio.post('/auth/login', data: {
'email': email,
'password': password,
});
final authResponse = AuthResponse.fromJson(response.data);
await _saveAuthData(authResponse);
return authResponse.user;
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Register new user
Future<User> register({
required String email,
required String password,
required String name,
}) async {
try {
final response = await _dio.post('/auth/register', data: {
'email': email,
'password': password,
'name': name,
});
final authResponse = AuthResponse.fromJson(response.data);
await _saveAuthData(authResponse);
return authResponse.user;
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Get current user from API
Future<User> getCurrentUser() async {
try {
final response = await _dio.get('/auth/me');
return User.fromJson(response.data);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Get cached user from storage
Future<User?> getCachedUser() async {
final userData = await _storage.getUserData();
if (userData != null) {
return User.fromJson(jsonDecode(userData));
}
return null;
}
/// Logout
Future<void> logout() async {
try {
await _dio.post('/auth/logout');
} catch (_) {
// Ignore errors on logout
} finally {
await _storage.clearAuthData();
}
}
/// Request password reset
Future<void> requestPasswordReset(String email) async {
try {
await _dio.post('/auth/forgot-password', data: {'email': email});
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Check if user has valid session
Future<bool> hasValidSession() async {
return await _storage.hasValidSession();
}
Future<void> _saveAuthData(AuthResponse response) async {
await _storage.saveAccessToken(response.accessToken);
await _storage.saveRefreshToken(response.refreshToken);
await _storage.saveUserData(jsonEncode(response.user.toJson()));
}
String _handleDioError(DioException e) {
if (e.response?.data != null && e.response?.data['message'] != null) {
return e.response!.data['message'];
}
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'Connection timeout. Please check your internet connection.';
case DioExceptionType.connectionError:
return 'No internet connection.';
default:
return 'Something went wrong. Please try again.';
}
}
}
Auth Controller
// presentation/controllers/auth_controller.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/auth_repository.dart';
import '../../domain/auth_state.dart';
final authControllerProvider =
StateNotifierProvider<AuthController, AuthState>((ref) {
return AuthController(ref.read(authRepositoryProvider));
});
class AuthController extends StateNotifier<AuthState> {
final AuthRepository _repository;
AuthController(this._repository) : super(const AuthState.initial());
/// Check authentication status on app start
Future<void> checkAuthStatus() async {
state = const AuthState.loading();
try {
final hasSession = await _repository.hasValidSession();
if (!hasSession) {
state = const AuthState.unauthenticated();
return;
}
// Try to get cached user first for faster startup
final cachedUser = await _repository.getCachedUser();
if (cachedUser != null) {
state = AuthState.authenticated(cachedUser);
// Refresh user data in background
_refreshUserInBackground();
} else {
// No cached user, fetch from API
final user = await _repository.getCurrentUser();
state = AuthState.authenticated(user);
}
} catch (e) {
state = const AuthState.unauthenticated();
}
}
/// Login with email and password
Future<void> login(String email, String password) async {
state = const AuthState.loading();
try {
final user = await _repository.login(email, password);
state = AuthState.authenticated(user);
} catch (e) {
state = AuthState.error(e.toString());
}
}
/// Register new user
Future<void> register({
required String email,
required String password,
required String name,
}) async {
state = const AuthState.loading();
try {
final user = await _repository.register(
email: email,
password: password,
name: name,
);
state = AuthState.authenticated(user);
} catch (e) {
state = AuthState.error(e.toString());
}
}
/// Logout
Future<void> logout() async {
state = const AuthState.loading();
await _repository.logout();
state = const AuthState.unauthenticated();
}
/// Request password reset
Future<bool> requestPasswordReset(String email) async {
try {
await _repository.requestPasswordReset(email);
return true;
} catch (e) {
state = AuthState.error(e.toString());
return false;
}
}
/// Clear error state
void clearError() {
state = const AuthState.unauthenticated();
}
void _refreshUserInBackground() async {
try {
final user = await _repository.getCurrentUser();
// Only update if still authenticated
if (state is _Authenticated) {
state = AuthState.authenticated(user);
}
} catch (_) {
// Ignore background refresh errors
}
}
}
Login Page
// presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../controllers/auth_controller.dart';
import '../../domain/auth_state.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final emailController = useTextEditingController();
final passwordController = useTextEditingController();
final obscurePassword = useState(true);
final authState = ref.watch(authControllerProvider);
// Listen for authentication changes
ref.listen<AuthState>(authControllerProvider, (previous, next) {
next.whenOrNull(
authenticated: (_) => context.go('/home'),
error: (message) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Dismiss',
textColor: Colors.white,
onPressed: () => ref.read(authControllerProvider.notifier).clearError(),
),
),
),
);
});
final isLoading = authState is _Loading;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
// Logo or App Name
const Icon(
Icons.lock_outline,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Email Field
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !isLoading,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: passwordController,
obscureText: obscurePassword.value,
textInputAction: TextInputAction.done,
enabled: !isLoading,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
obscurePassword.value
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => obscurePassword.value = !obscurePassword.value,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 8),
// Forgot Password
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: isLoading ? null : () => context.push('/forgot-password'),
child: const Text('Forgot Password?'),
),
),
const SizedBox(height: 24),
// Login Button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: isLoading
? null
: () {
if (formKey.currentState!.validate()) {
ref.read(authControllerProvider.notifier).login(
emailController.text.trim(),
passwordController.text,
);
}
},
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
),
),
const SizedBox(height: 24),
// Divider
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'OR',
style: TextStyle(color: Colors.grey[600]),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 24),
// Social Login Buttons
OutlinedButton.icon(
onPressed: isLoading ? null : () => _loginWithGoogle(ref),
icon: Image.asset('assets/google_logo.png', height: 24),
label: const Text('Continue with Google'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 32),
// Register Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Don't have an account?"),
TextButton(
onPressed: isLoading ? null : () => context.push('/register'),
child: const Text('Sign Up'),
),
],
),
],
),
),
),
),
);
}
void _loginWithGoogle(WidgetRef ref) {
// Implement Google Sign-In
}
}
Register Page
// presentation/pages/register_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../controllers/auth_controller.dart';
import '../../domain/auth_state.dart';
class RegisterPage extends HookConsumerWidget {
const RegisterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final nameController = useTextEditingController();
final emailController = useTextEditingController();
final passwordController = useTextEditingController();
final confirmPasswordController = useTextEditingController();
final obscurePassword = useState(true);
final acceptedTerms = useState(false);
final authState = ref.watch(authControllerProvider);
final isLoading = authState is _Loading;
ref.listen<AuthState>(authControllerProvider, (previous, next) {
next.whenOrNull(
authenticated: (_) => context.go('/home'),
error: (message) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
),
);
});
return Scaffold(
appBar: AppBar(
title: const Text('Create Account'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Name Field
TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
enabled: !isLoading,
decoration: const InputDecoration(
labelText: 'Full Name',
prefixIcon: Icon(Icons.person_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your name';
}
if (value.length < 2) {
return 'Name must be at least 2 characters';
}
return null;
},
),
const SizedBox(height: 16),
// Email Field
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !isLoading,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: passwordController,
obscureText: obscurePassword.value,
textInputAction: TextInputAction.next,
enabled: !isLoading,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
obscurePassword.value
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => obscurePassword.value = !obscurePassword.value,
),
helperText: 'At least 8 characters with a number',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Password must contain at least one number';
}
return null;
},
),
const SizedBox(height: 16),
// Confirm Password Field
TextFormField(
controller: confirmPasswordController,
obscureText: obscurePassword.value,
textInputAction: TextInputAction.done,
enabled: !isLoading,
decoration: const InputDecoration(
labelText: 'Confirm Password',
prefixIcon: Icon(Icons.lock_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value != passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 16),
// Terms Checkbox
CheckboxListTile(
value: acceptedTerms.value,
onChanged: isLoading ? null : (v) => acceptedTerms.value = v ?? false,
title: Text.rich(
TextSpan(
text: 'I agree to the ',
children: [
TextSpan(
text: 'Terms of Service',
style: TextStyle(color: Theme.of(context).primaryColor),
),
const TextSpan(text: ' and '),
TextSpan(
text: 'Privacy Policy',
style: TextStyle(color: Theme.of(context).primaryColor),
),
],
),
style: Theme.of(context).textTheme.bodySmall,
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 24),
// Register Button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: isLoading || !acceptedTerms.value
? null
: () {
if (formKey.currentState!.validate()) {
ref.read(authControllerProvider.notifier).register(
email: emailController.text.trim(),
password: passwordController.text,
name: nameController.text.trim(),
);
}
},
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Account'),
),
),
const SizedBox(height: 24),
// Login Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Already have an account?'),
TextButton(
onPressed: isLoading ? null : () => context.pop(),
child: const Text('Sign In'),
),
],
),
],
),
),
),
),
);
}
}
Protected Routing with GoRouter
// core/router/app_router.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/controllers/auth_controller.dart';
import '../../features/auth/domain/auth_state.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authControllerProvider);
return GoRouter(
initialLocation: '/splash',
debugLogDiagnostics: true,
redirect: (context, state) {
final isAuthenticated = authState is _Authenticated;
final isLoading = authState is _Loading || authState is _Initial;
final isOnAuthPage = state.matchedLocation == '/login' ||
state.matchedLocation == '/register' ||
state.matchedLocation == '/forgot-password';
final isOnSplash = state.matchedLocation == '/splash';
// Show splash while checking auth
if (isLoading && !isOnSplash) {
return '/splash';
}
// Redirect to login if not authenticated
if (!isAuthenticated && !isLoading && !isOnAuthPage) {
return '/login';
}
// Redirect to home if authenticated and on auth page
if (isAuthenticated && (isOnAuthPage || isOnSplash)) {
return '/home';
}
return null;
},
routes: [
GoRoute(
path: '/splash',
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterPage(),
),
GoRoute(
path: '/forgot-password',
builder: (context, state) => const ForgotPasswordPage(),
),
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
),
],
);
});
Common Mistakes to Avoid
1. Storing Tokens Insecurely
// BAD: Using SharedPreferences for tokens
final prefs = await SharedPreferences.getInstance();
await prefs.setString('token', token); // Not encrypted!
// GOOD: Use flutter_secure_storage
final storage = FlutterSecureStorage();
await storage.write(key: 'token', value: token); // Encrypted!
2. Not Handling Token Expiration
// BAD: Assuming token is always valid
final token = await storage.getAccessToken();
// Use token directly without checking expiration
// GOOD: Implement token refresh in interceptor
if (response.statusCode == 401) {
final refreshed = await _refreshToken();
if (refreshed) {
return _retry(requestOptions);
}
}
3. Missing Form Validation
// BAD: No validation before submission
onPressed: () => ref.read(authControllerProvider.notifier).login(
emailController.text,
passwordController.text,
);
// GOOD: Validate form first
onPressed: () {
if (formKey.currentState!.validate()) {
ref.read(authControllerProvider.notifier).login(
emailController.text.trim(),
passwordController.text,
);
}
}
4. Not Clearing Sensitive Data on Logout
// BAD: Only clearing state
Future<void> logout() async {
state = const AuthState.unauthenticated();
}
// GOOD: Clear all stored data
Future<void> logout() async {
await _repository.logout(); // Clears tokens and user data
state = const AuthState.unauthenticated();
}
Testing the Auth Flow
// test/auth_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late MockAuthRepository mockRepository;
late ProviderContainer container;
setUp(() {
mockRepository = MockAuthRepository();
container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
});
tearDown(() => container.dispose());
test('login success updates state to authenticated', () async {
final user = User(id: '1', email: 'test@example.com');
when(() => mockRepository.login(any(), any())).thenAnswer((_) async => user);
final controller = container.read(authControllerProvider.notifier);
await controller.login('test@example.com', 'password');
final state = container.read(authControllerProvider);
expect(state, isA<_Authenticated>());
});
test('login failure updates state to error', () async {
when(() => mockRepository.login(any(), any()))
.thenThrow('Invalid credentials');
final controller = container.read(authControllerProvider.notifier);
await controller.login('test@example.com', 'wrong');
final state = container.read(authControllerProvider);
expect(state, isA<_Error>());
});
}
Final Thoughts
Using Riverpod for your login/register flow gives you asynchronous control, better testing, and clean separation of concerns. The combination of Freezed for immutable state, secure storage for tokens, and Dio interceptors for automatic token refresh creates a robust authentication system.
Start with this foundation and extend it with social login providers, biometric authentication, and multi-factor authentication as your app grows.
For more Flutter architecture patterns, check out Clean Architecture with BLoC in Flutter. For integrating with Firebase authentication, see Building a Chat App in Flutter with Firebase. For the official Riverpod documentation, visit riverpod.dev.
1 Comment