flutter
/

BLoC Navigation Guards: Protect Routes with Authentication & Authorization

Last Sync: Today

On this page

10
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Navigation Guards: Protect Routes with Authentication & Authorization

What are Navigation Guards?

Navigation guards are mechanisms that control access to certain routes based on conditions like authentication status, user roles, or feature flags. They prevent unauthorized users from accessing protected screens and redirect them to login or permission-denied pages. In BLoC architecture, navigation guards are implemented by observing the auth state and conditionally navigating or using route observers to intercept navigation attempts.

Why Use BLoC for Navigation Guards?

  • Centralized Auth State – BLoC holds the single source of truth for authentication.
  • Reactive Navigation – Guards automatically respond to state changes (e.g., logout redirects to login).
  • Separation of Concerns – Navigation logic stays in the UI layer; BLoC only manages state.
  • Testability – Auth state changes can be tested independently of navigation.
  • Consistency – Ensure all routes are protected using the same auth state.

Setup: Authentication BLoC

First, create an authentication BLoC that manages the user's auth state. This will be the source for all navigation decisions.

DARTRead-only
1
// auth_state.dart
enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState extends Equatable {
  final AuthStatus status;
  final User? user;
  final String? error;

  const AuthState({this.status = AuthStatus.unknown, this.user, this.error});

  AuthState copyWith({AuthStatus? status, User? user, String? error}) {
    return AuthState(
      status: status ?? this.status,
      user: user ?? this.user,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [status, user, error];
}

// auth_event.dart
abstract class AuthEvent extends Equatable { ... }
class AppStarted extends AuthEvent {}
class LoggedIn extends AuthEvent { final User user; ... }
class LoggedOut extends AuthEvent {}

// auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(const AuthState()) {
    on<AppStarted>(_onAppStarted);
    on<LoggedIn>(_onLoggedIn);
    on<LoggedOut>(_onLoggedOut);
  }
  // ... implementation
}

Navigation Guard with BlocListener (Imperative)

Place a BlocListener at the top of your widget tree to listen to auth state changes and redirect accordingly. This works with both Navigator 1.0 and 2.0.

DARTRead-only
1
class AppRouter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state.status == AuthStatus.authenticated) {
          // User is logged in, navigate to home if currently on login/splash
          if (ModalRoute.of(context)?.settings.name == '/login') {
            Navigator.pushReplacementNamed(context, '/home');
          }
        } else if (state.status == AuthStatus.unauthenticated) {
          // User is logged out, navigate to login
          Navigator.pushReplacementNamed(context, '/login');
        }
      },
      child: MaterialApp(
        initialRoute: '/splash',
        routes: {
          '/splash': (context) => SplashPage(),
          '/login': (context) => LoginPage(),
          '/home': (context) => HomePage(),
          '/profile': (context) => ProfilePage(),
        },
      ),
    );
  }
}

Protecting Individual Routes

For individual routes that should be guarded, you can check the auth state before navigating or use a custom RouteGuard widget that wraps the route content.

DARTRead-only
1
class ProtectedRoute extends StatelessWidget {
  final Widget child;
  const ProtectedRoute({required this.child});

  @override
  Widget build(BuildContext context) {
    final authState = context.watch<AuthBloc>().state;
    if (authState.status == AuthStatus.authenticated) {
      return child;
    } else {
      // Redirect to login
      WidgetsBinding.instance.addPostFrameCallback((_) {
        Navigator.pushReplacementNamed(context, '/login');
      });
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }
  }
}

// Usage in routes:
'/profile': (context) => ProtectedRoute(child: ProfilePage()),

Integration with GoRouter (Declarative Navigation)

GoRouter is a modern routing package that works seamlessly with BLoC. You can define a redirect function that reads the BLoC state and determines the appropriate route.

DARTRead-only
1
import 'package:go_router/go_router.dart';

final router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    final authState = context.read<AuthBloc>().state;
    final isLoggedIn = authState.status == AuthStatus.authenticated;
    final isGoingToLogin = state.matchedLocation == '/login';

    if (!isLoggedIn && !isGoingToLogin) {
      return '/login';
    }
    if (isLoggedIn && isGoingToLogin) {
      return '/';
    }
    return null;
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => const LoginPage(),
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => const ProfilePage(),
    ),
  ],
);

// In main:
runApp(BlocProvider(
  create: (_) => AuthBloc()..add(AppStarted()),
  child: MaterialApp.router(
    routerConfig: router,
  ),
));

Role-Based Guards (Authorization)

You can extend guards to check user roles. For example, only admin users can access an admin panel.

DARTRead-only
1
class RoleGuard extends StatelessWidget {
  final List<String> allowedRoles;
  final Widget child;
  final Widget? forbiddenWidget;

  const RoleGuard({
    required this.allowedRoles,
    required this.child,
    this.forbiddenWidget,
  });

  @override
  Widget build(BuildContext context) {
    final authState = context.watch<AuthBloc>().state;
    if (authState.status == AuthStatus.authenticated &&
        allowedRoles.contains(authState.user?.role)) {
      return child;
    }
    return forbiddenWidget ??
        const Scaffold(
          body: Center(child: Text('Access Denied')),
        );
  }
}

// Usage in GoRouter:
GoRoute(
  path: '/admin',
  builder: (context, state) => RoleGuard(
    allowedRoles: ['admin'],
    child: AdminPage(),
  ),
);

Best Practices

  • Use a single source of truth – Auth state should be managed in a single BLoC (or multiple if needed) and accessed via providers.
  • Redirect early – In GoRouter, the redirect function runs before building any route, preventing flicker.
  • Avoid side effects in builders – Use BlocListener or addPostFrameCallback for navigation to avoid calling during build.
  • Test guards – Verify that unauthenticated users cannot access protected routes in widget tests.
  • Combine with hydrated_bloc – Persist auth state across app restarts to maintain login.
  • Show loading states – While checking auth, display a splash screen to prevent UI jumps.

Common Mistakes

  • ❌ Calling Navigator.push inside a build method – This will run multiple times, causing navigation loops. Use BlocListener or a post-frame callback.
  • ❌ Not handling unknown auth state – App may try to navigate before auth state is resolved, causing redirect loops.
  • ❌ Hardcoding routes in the BLoC – BLoC should not know about navigation; keep navigation logic in the UI layer.
  • ❌ Forgetting to dispose of subscriptions – If using a stream to listen to auth changes, ensure it's closed.
  • ❌ Not testing guard behavior – Always write tests to ensure protected routes redirect correctly.

Conclusion

Navigation guards are essential for securing your Flutter app. By combining BLoC's auth state with tools like BlocListener or GoRouter's redirect, you can implement robust authentication and authorization flows. These patterns keep your code maintainable and your app secure.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

void main() => runApp(MyApp());

// ---------- Auth BLoC ----------
enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState extends Equatable {
  final AuthStatus status;
  final String? userName;
  final String? role;
  final String? error;

  const AuthState({
    this.status = AuthStatus.unknown,
    this.userName,
    this.role,
    this.error,
  });

  AuthState copyWith({
    AuthStatus? status,
    String? userName,
    String? role,
    String? error,
  }) {
    return AuthState(
      status: status ?? this.status,
      userName: userName ?? this.userName,
      role: role ?? this.role,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [status, userName, role, error];
}

abstract class AuthEvent extends Equatable {
  const AuthEvent();
  @override List<Object?> get props => [];
}

class AppStarted extends AuthEvent {}
class LoginRequested extends AuthEvent {
  final String username;
  final String password;
  const LoginRequested(this.username, this.password);
}
class LogoutRequested extends AuthEvent {}

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(const AuthState()) {
    on<AppStarted>(_onAppStarted);
    on<LoginRequested>(_onLoginRequested);
    on<LogoutRequested>(_onLogoutRequested);
  }

  void _onAppStarted(AppStarted event, Emitter<AuthState> emit) {
    // Check for persisted auth (simulate)
    emit(state.copyWith(status: AuthStatus.unauthenticated));
  }

  Future<void> _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
    emit(state.copyWith(status: AuthStatus.unknown, error: null));
    await Future.delayed(Duration(seconds: 1));
    if (event.username == 'admin' && event.password == 'admin') {
      emit(state.copyWith(
        status: AuthStatus.authenticated,
        userName: event.username,
        role: 'admin',
      ));
    } else if (event.username == 'user' && event.password == 'user') {
      emit(state.copyWith(
        status: AuthStatus.authenticated,
        userName: event.username,
        role: 'user',
      ));
    } else {
      emit(state.copyWith(
        status: AuthStatus.unauthenticated,
        error: 'Invalid credentials',
      ));
    }
  }

  void _onLogoutRequested(LogoutRequested event, Emitter<AuthState> emit) {
    emit(const AuthState(status: AuthStatus.unauthenticated));
  }
}

// ---------- Navigation Guard with BlocListener ----------
class AppRouter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state.status == AuthStatus.unauthenticated) {
          // Only navigate to login if not already there
          final currentRoute = ModalRoute.of(context)?.settings.name;
          if (currentRoute != '/login') {
            Navigator.pushReplacementNamed(context, '/login');
          }
        } else if (state.status == AuthStatus.authenticated) {
          final currentRoute = ModalRoute.of(context)?.settings.name;
          if (currentRoute == '/login') {
            Navigator.pushReplacementNamed(context, '/');
          }
        }
      },
      child: MaterialApp(
        initialRoute: '/splash',
        routes: {
          '/splash': (context) => SplashPage(),
          '/login': (context) => LoginPage(),
          '/': (context) => HomePage(),
          '/admin': (context) => AdminPage(),
        },
      ),
    );
  }
}

// ---------- UI Pages ----------
class SplashPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Simulate loading
    Future.delayed(Duration(milliseconds: 500), () {
      if (context.mounted) {
        Navigator.pushReplacementNamed(context, '/login');
      }
    });
    return Scaffold(body: Center(child: CircularProgressIndicator()));
  }
}

class LoginPage extends StatefulWidget {
  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final TextEditingController userController = TextEditingController();
  final TextEditingController passController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(controller: userController, decoration: InputDecoration(labelText: 'Username')),
            TextField(controller: passController, obscureText: true, decoration: InputDecoration(labelText: 'Password')),
            SizedBox(height: 20),
            BlocConsumer<AuthBloc, AuthState>(
              listener: (context, state) {
                if (state.error != null) {
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text(state.error!)),
                  );
                }
              },
              builder: (context, state) {
                if (state.status == AuthStatus.unknown) {
                  return CircularProgressIndicator();
                }
                return ElevatedButton(
                  onPressed: () {
                    context.read<AuthBloc>().add(LoginRequested(
                      userController.text,
                      passController.text,
                    ));
                  },
                  child: Text('Login'),
                );
              },
            ),
            SizedBox(height: 20),
            Text('Demo: admin/admin or user/user'),
          ],
        ),
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final authState = context.watch<AuthBloc>().state;
    return Scaffold(
      appBar: AppBar(
        title: Text('Home'),
        actions: [
          IconButton(
            icon: Icon(Icons.logout),
            onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Welcome, ${authState.userName}!'),
            Text('Role: ${authState.role}'),
            SizedBox(height: 20),
            if (authState.role == 'admin')
              ElevatedButton(
                onPressed: () => Navigator.pushNamed(context, '/admin'),
                child: Text('Go to Admin Page'),
              ),
          ],
        ),
      ),
    );
  }
}

class AdminPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final authState = context.watch<AuthBloc>().state;
    if (authState.role != 'admin') {
      return Scaffold(
        body: Center(child: Text('Access Denied')),
      );
    }
    return Scaffold(
      appBar: AppBar(title: Text('Admin Panel')),
      body: Center(child: Text('Secret admin content')),
    );
  }
}

// ---------- Main ----------
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => AuthBloc()..add(AppStarted()),
      child: AppRouter(),
    );
  }
}

Test Your Knowledge

Q1
of 4

Which widget is best suited to listen to auth state changes and perform navigation?

A
BlocBuilder
B
BlocListener
C
BlocProvider
D
BlocConsumer
Q2
of 4

In GoRouter, how do you implement route guards based on auth state?

A
Using a custom NavigatorObserver
B
Using the redirect parameter
C
Wrapping each route with a Guard widget
D
Using BlocBuilder inside the route builder
Q3
of 4

Why should you avoid calling Navigator.push inside a build method?

A
It's not supported
B
It can cause multiple navigations due to rebuilds
C
It's slower
D
It breaks animations
Q4
of 4

How can you implement role-based access to a route?

A
Store role in global variable
B
Check the user's role in the guard before showing the page
C
Use different BLoCs for each role
D
It's not possible

Frequently Asked Questions

Can I use navigation guards with Navigator 2.0 without GoRouter?

Yes, you can implement a custom RouterDelegate that listens to the BLoC state and updates the route stack accordingly. However, GoRouter simplifies this greatly and is recommended.

How do I handle deep links that point to protected routes?

In GoRouter, the redirect function will also run for deep links, so you can redirect unauthenticated users to login and then redirect back after login. You'll need to preserve the original location.

Should I store the auth state in a global BLoC or use a provider?

A single AuthBloc at the top of the widget tree is typical. Use BlocProvider to make it available everywhere. For role-based guards, you can read the user from the same state.

How do I test navigation guards?

Use blocTest to verify that the correct states are emitted. For widget tests, use tester.pumpWidget with a mocked AuthBloc and verify that the expected route is shown after navigation.

Previous

bloc deeplink navigation

Next

bloc dio integration

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.