Sitemap
Nisarg Ratani

Nisarg Ratani

Flutter & Node.js developer with 6+ years of experience. Published few articles on Medium, sharing practical insights on Flutter, Dart, and Node.js.

Flutter Navigation Guards: Authentication, Authorization & Route Redirects

8 min read13 hours ago

--

Navigation is more than moving users from one screen to another.

In a production application, not every screen should be accessible to every user.

Consider an application with:

  • Login and registration
  • User dashboard
  • Profile
  • Admin panel
  • Subscription-only features
  • Business management screens

A logged-out user should not access protected screens. A regular user should not access administrator-only pages. A user whose subscription has expired may need to be redirected to a subscription screen.

This is where navigation guards become important.

In Flutter, navigation guards are commonly implemented through route redirects, authentication state, authorization checks, and application-level access policies.

This article explains how to build them using GoRouter and how to keep the implementation maintainable.

1. What Are Navigation Guards?

A navigation guard is logic that determines whether a user can navigate to a particular route.

Conceptually:

User requests a route
↓
Evaluate access conditions
↓
Is the user authenticated?
↓
Does the user have permission?
↓
Does the user meet other requirements?
↓
Allow navigation or redirect

For example:

/dashboard
↓
Authenticated?
├── Yes → Open dashboard
└── No → Redirect to login

A guard can evaluate several conditions:

  • Authentication status
  • User role
  • Account verification
  • Subscription status
  • Business membership
  • Feature availability
  • Onboarding completion

The important distinction is that authentication and authorization are different concerns.

2. Authentication vs Authorization

Authentication answers:

Who is the user?

Authorization answers:

What is this user allowed to do?

For example:

Press enter or click to view image in full size

A logged-in user is not automatically authorized to access every resource.

3. Public and Protected Routes

Start by classifying your routes.

Public
├── /
├── /login
├── /register
├── /forgot-password
└── /terms

Protected
├── /dashboard
├── /profile
├── /settings
└── /orders
Role protected
├── /admin
└── /admin/users
Subscription protected
└── /premium-reports

This classification makes your navigation policy easier to understand.

4. Model Authentication State Explicitly

Avoid relying on a simple boolean if your application has asynchronous authentication initialization.

For example:

enum AuthStatus {
initializing,
unauthenticated,
authenticated,
}

A more expressive state model might look like:

sealed class AuthState {
const AuthState();
}

class AuthInitializing extends AuthState {
const AuthInitializing();
}
class Unauthenticated extends AuthState {
const Unauthenticated();
}
class Authenticated extends AuthState {
const Authenticated({
required this.user,
});
final AppUser user;
}

Your AppUser can contain application-level information:

class AppUser {
const AppUser({
required this.id,
required this.role,
required this.isEmailVerified,
});

final String id;
final UserRole role;
final bool isEmailVerified;
}

enum UserRole {
user,
manager,
admin,
}

The domain model should represent your application’s actual requirements rather than being coupled to a Firebase or other authentication SDK user object.

5. Why an Initializing State Matters

Imagine that the application launches and immediately opens:

/dashboard

The authentication SDK hasn’t finished restoring the user’s session.

If you treat the initial state as unauthenticated, the router may redirect to:

/login

even though the user already has a valid session.

This can cause:

  • Login screen flashes
  • Unnecessary redirects
  • Poor launch experience
  • Incorrect deep-link handling

Instead, represent the initialization phase explicitly.

App starts
↓
Auth initializing
↓
Restore session
↓
Authenticated or unauthenticated
↓
Router evaluates access

You can display a splash or loading screen while the initial authentication state is being resolved.

6. Implementing Guards with GoRouter

GoRouter provides a redirect callback that can decide whether a navigation request should be redirected.

A simplified example:

final router = GoRouter(
initialLocation: '/dashboard',

redirect: (context, state) {
final isLoggedIn = authService.isLoggedIn;
final isLoggingIn =
state.matchedLocation == '/login';
if (!isLoggedIn && !isLoggingIn) {
return '/login';
}
if (isLoggedIn && isLoggingIn) {
return '/dashboard';
}
return null;
},
routes: [
GoRoute(
path: '/login',
builder: (context, state) {
return const LoginPage();
},
),
GoRoute(
path: '/dashboard',
builder: (context, state) {
return const DashboardPage();
},
),
],
);

Returning null means the requested navigation can continue.

Returning a route location asks GoRouter to redirect.

This example demonstrates the basic idea. A production implementation needs to handle initialization, refresh notifications, intended destinations, and authorization.

7. Refreshing GoRouter When Authentication Changes

A redirect callback is not a continuous background monitor.

If the authentication state changes while the application is running, the router needs to reevaluate its redirect policy.

GoRouter supports a refreshListenable mechanism.

You can expose authentication changes through a Listenable.

class RouterRefreshNotifier extends ChangeNotifier {
RouterRefreshNotifier(
Stream<Object?> stream,
) {
_subscription = stream.listen(
(_) => notifyListeners(),
);
}

late final StreamSubscription<Object?> _subscription;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}

Then:

final refreshNotifier = RouterRefreshNotifier(
authStateChanges,
);

final router = GoRouter(
refreshListenable: refreshNotifier,
redirect: (context, state) {
// Evaluate current authentication state.
return null;
},
routes: [
// Routes
],
);

The stream should emit whenever the relevant authentication state changes.

Dispose the notifier and its subscription when the router is no longer needed.

If you’re using Riverpod, you can also integrate the router with provider state. Keep the router’s lifecycle stable rather than recreating it on every widget rebuild.

8. Handle Authentication Initialization

A production redirect policy should distinguish initialization from a confirmed logged-out state.

For example:

String? authRedirect({
required AuthStatus status,
required String location,
}) {
final isPublicRoute =
location == '/login' ||
location == '/register';

if (status == AuthStatus.initializing) {
return '/splash';
}
if (status == AuthStatus.unauthenticated &&
!isPublicRoute) {
return '/login';
}
if (status == AuthStatus.authenticated &&
isPublicRoute) {
return '/dashboard';
}
return null;
}

In a real application, avoid redirect loops. Your splash route must be allowed while authentication is initializing.

You should also decide how to handle deep links received during initialization. Preserve the intended destination and resume navigation after the session has been restored.

9. Preserve the Intended Destination

Suppose a user opens:

/products/123

but isn’t logged in.

You redirect them to:

/login

After successful login, the best experience is usually to take them back to:

/products/123

One approach is to pass the intended destination as a query parameter.

final destination = Uri.encodeComponent(
state.uri.toString(),
);

return '/login?from=$destination';

After login:

final from = state.uri.queryParameters['from'];
final destination =
from != null && from.startsWith('/')
? from
: '/dashboard';
context.go(destination);

Treat redirect destinations as untrusted input.

Get Nisarg Ratani’s stories in your inbox

Join Medium for free to get updates from this writer.

For an application that only supports internal navigation, validate that the destination is a permitted internal path. Do not blindly navigate to arbitrary external URLs supplied through a query parameter.

10. Authorization Based on Roles

Authentication alone isn’t enough for role-protected routes.

For example:

enum UserRole {
user,
manager,
admin,
}

A simple policy:

bool canAccessAdmin(UserRole role) {
return role == UserRole.admin;
}

Use it in your route redirect:

redirect: (context, state) {
final user = authService.currentUser;

if (state.matchedLocation == '/admin') {
if (user == null) {
return '/login';
}
if (user.role != UserRole.admin) {
return '/forbidden';
}
}
return null;
},

For larger systems, avoid putting every permission rule directly inside one redirect callback.

Create a dedicated authorization policy or service.

11. Permission-Based Authorization

Roles can become difficult to manage when your application has many independent capabilities.

For example:

User
├── View products
├── Create orders
└── View own invoices

Manager
├── View products
├── Create orders
├── View invoices
└── Manage inventory

Admin
├── All manager permissions
├── Manage users
└── Configure application

A permission model is more flexible:

enum Permission {
viewProducts,
createOrders,
viewInvoices,
manageInventory,
manageUsers,
}

Then:

bool hasPermission(
Set<Permission> permissions,
Permission required,
) {
return permissions.contains(required);
}

This is useful for business applications with configurable roles and permissions.

12. Subscription-Based Route Guards

Consider a premium report:

/reports/advanced

A user may be authenticated but may not have an active subscription.

Your route policy can check an entitlement:

if (state.matchedLocation ==
'/reports/advanced') {
if (!subscriptionService.hasPremiumAccess) {
return '/subscription';
}
}

However, subscription state should be refreshed and validated according to your billing architecture.

Do not treat a locally stored boolean as authoritative proof of payment.

For RevenueCat or another subscription provider, use the provider’s entitlement information and verify access in any backend operation that requires stronger protection.

13. Account Verification Guards

Some applications require email or phone verification before allowing sensitive actions.

For example:

if (!user.isEmailVerified &&
state.matchedLocation == '/checkout') {
return '/verify-email';
}

Avoid applying verification requirements globally unless your product genuinely requires it.

A user may need access to profile or account settings to complete verification.

14. Avoid One Giant Redirect Function

As route policies grow, a single redirect callback can become difficult to maintain.

Instead, separate the decisions.

class NavigationPolicy {
String? redirect({
required AuthState authState,
required String location,
}) {
if (authState is AuthInitializing) {
return '/splash';
}

if (authState is Unauthenticated) {
return _handleUnauthenticated(location);
}
if (authState is Authenticated) {
return _handleAuthenticated(
authState.user,
location,
);
}
return null;
}
String? _handleUnauthenticated(
String location,
) {
final publicRoutes = {
'/login',
'/register',
'/forgot-password',
'/splash',
};
if (publicRoutes.contains(location)) {
return null;
}
return '/login';
}
String? _handleAuthenticated(
AppUser user,
String location,
) {
if (location == '/login' ||
location == '/register') {
return '/dashboard';
}
if (location == '/admin' &&
user.role != UserRole.admin) {
return '/forbidden';
}
return null;
}
}

This is only an illustrative structure. Real route matching should account for nested routes and dynamic paths, rather than relying exclusively on exact string equality.

15. Organize Routes by Access Requirements

A route configuration can be grouped by feature:

routes/
├── app_router.dart
├── route_names.dart
├── navigation_policy.dart
├── auth_routes.dart
├── dashboard_routes.dart
├── product_routes.dart
└── admin_routes.dart

You can also define route metadata or reusable policy helpers when the number of protected routes becomes large.

The goal is to make access requirements easy to review.

16. Navigation Guards Are Not Security Boundaries

This is one of the most important production principles.

A Flutter navigation guard improves the user experience. It does not secure your backend.

A user may:

  • Modify the application
  • Call an API directly
  • Change local storage
  • Use a modified client
  • Attempt requests outside your UI

Therefore:

Flutter route guard
↓
Controls navigation experience
Backend authorization
↓
Controls access to protected data and actions

Your backend must independently verify:

  • User identity
  • Resource ownership
  • Role and permissions
  • Subscription entitlement where required
  • Business membership
  • Request validity

For Firebase applications, use appropriate Firebase Security Rules and trusted backend logic. Never rely on hiding a screen as the only protection for its data.

17. Testing Navigation Guards

Test the access policy independently from the UI where possible.

Example test cases:

Press enter or click to view image in full size

Also test:

  • Deep links to protected routes
  • Login followed by return navigation
  • Logout while on a protected page
  • Expired sessions
  • Permission changes during a session
  • Redirect loops
  • Browser refresh on Flutter Web

18. Common Mistakes

Treating initialization as logged out

This can cause unnecessary login redirects.

Recreating the router frequently

This can reset navigation state and create unexpected behavior.

Checking only authentication

A logged-in user may still lack permission.

Losing the original destination

Users should not always be sent to the dashboard after login.

Trusting client-side authorization

Route guards cannot replace server-side access control.

Creating redirect loops

Every redirect target must be compatible with the guard rules.

Production Checklist

AUTHENTICATION
✓ Explicit initialization state
✓ Logged-out users redirected correctly
✓ Login and registration routes remain public
✓ Logout updates navigation

AUTHORIZATION
✓ Role checks
✓ Permission checks
✓ Resource ownership checked on backend
✓ Subscription access handled correctly

NAVIGATION
✓ Intended destination preserved
✓ Redirect targets validated
✓ No redirect loops
✓ Deep links handled
✓ Router refreshes on relevant state changes

SECURITY
✓ Backend authorization
✓ Firebase Security Rules where applicable
✓ No sensitive data exposed through client-only checks
✓ Session expiry handled

TESTING
✓ Authenticated scenarios
✓ Unauthenticated scenarios
✓ Admin scenarios
✓ Expired-session scenarios
✓ Deep-link scenarios
✓ Web refresh scenarios

Conclusion

Navigation guards are essential in applications that contain authentication, roles, permissions, subscriptions, or protected resources.

A maintainable implementation separates:

Authentication
Authorization
Navigation policy
Route configuration
Backend security

Use GoRouter redirects to provide a smooth navigation experience, but always enforce sensitive permissions on the backend.

A well-designed navigation system should not only take users to the right screen. It should also make sure they reach only the screens they are allowed to access.

--

--

Nisarg Ratani
Nisarg Ratani