Skip to content
Blog

GoRouter Redirect Guards: Building Authentication Flows in Flutter

Learn how GoRouter redirect guards work and how to build automatic authentication flows, role-based access, and deep-link safe sign-in redirects in Flutter.

Published on • October 8, 2026

AI Assistant

Most Flutter apps eventually hit the same wall: some routes require a signed-in user, some require a specific role, and a deep link must never drop a logged-out user onto a protected screen. go_router solves this with redirect guards — a declarative function that runs on every navigation and can reroute the user based on application state.

go_router is a Flutter Favorite published by the Flutter team itself, and it describes redirection as a first-class feature: “Redirection support - you can re-route the user to a different URL based on application state, for example to a sign-in when the user is not authenticated.”

How redirection works

The GoRouter constructor takes a redirect callback. It receives the current BuildContext and a RouteMatchList, and returns:

  • null — allow the navigation to proceed unchanged
  • a String — a new location to redirect to
  • a RouteMatchList — advanced case, e.g. redirecting to an error route
final router = GoRouter(
  refreshListenable: authNotifier,
  redirect: (context, state) {
    final loggedIn = authNotifier.isLoggedIn;
    final loggingIn = state.matchedLocation == '/login';

    if (!loggedIn && !loggingIn) return '/login';
    if (loggedIn && loggingIn) return '/home';
    return null;
  },
  routes: [
    GoRoute(path: '/login', builder: (ctx, st) => const LoginScreen()),
    GoRoute(path: '/home', builder: (ctx, st) => const HomeScreen()),
    GoRoute(path: '/settings', builder: (ctx, st) => const SettingsScreen()),
  ],
);

Two details matter here:

  1. refreshListenable — go_router re-evaluates the redirect whenever this Listenable fires. Without it, a login that flips your auth state won’t reroute the user; they would stay on the login screen until they manually navigate.
  2. The loggingIn escape hatch — without allowing /login through, the redirect would loop forever: not logged in → redirect to /login → still not logged in → redirect to /login.

Preserving the originally requested route

The classic broken flow is: user taps a deep link to /settings, gets bounced to /login, signs in, and lands on /home instead of /settings. Fix it by carrying the target location through the login route:

redirect: (context, state) {
  final loggedIn = authNotifier.isLoggedIn;
  final location = state.matchedLocation;
  final loggingIn = location.startsWith('/login');

  if (!loggedIn) {
    return loggingIn
        ? null
        : '/login?from=${Uri.encodeComponent(location)}';
  }
  if (loggingIn) {
    final from = state.uri.queryParameters['from'];
    return from != null && from.isNotEmpty ? from : '/home';
  }
  return null;
},

On login success, read state.uri.queryParameters['from'] and navigate there. This also works for the classic “session expired mid-navigation” case, because the guard reruns on the next navigation.

Route-level redirects

Guards don’t have to live in one giant function. Each GoRoute accepts its own redirect, which runs after the top-level one:

GoRoute(
  path: '/admin',
  redirect: (context, state) {
    final role = authNotifier.role;
    if (role != 'admin') return '/forbidden';
    return null;
  },
  builder: (context, state) => const AdminDashboard(),
),

This gives you a scoping model that mirrors your app:

Guard typeLives inBest for
Global redirectGoRouter(redirect:)Authentication, onboarding flags
Route redirectGoRoute(redirect:)Roles, entitlements, feature flags
Nested sub-route redirectChild GoRoutePer-tab gating inside a shell

Because route-level redirects run top-down through the matched route chain, a parent shell route can enforce auth once for every screen beneath it.

Redirects and shell routes

Nested navigation with ShellRoute interacts with guards in a way that trips people up. The shell itself is a route, so a redirect on the shell applies to all of its children, but a redirect on a child does not re-render the shell.

ShellRoute(
  redirect: (context, state) =>
      authNotifier.isLoggedIn ? null : '/login',
  builder: (context, state, child) => AppShell(child: child),
  routes: [
    GoRoute(path: '/feed', builder: (c, s) => const FeedScreen()),
    GoRoute(path: '/profile', builder: (c, s) => const ProfileScreen()),
  ],
),

Sign out while sitting on /feed and refreshListenable fires, the shell redirect matches, and the whole shell — including the bottom navigation bar — is replaced by the login screen.

Async redirects

Sometimes the guard needs an answer you don’t have yet: is this subscription still valid? does this workspace exist? The redirect callback must return synchronously, so gate the app behind a resolved bootstrap state instead of awaiting inside the callback:

enum SessionState { unknown, ready }

// In your bootstrap:
await auth.restoreSession();
sessionState.value = SessionState.ready;

redirect: (context, state) {
  if (sessionState.value == SessionState.unknown) {
    return '/splash';
  }
  // ...normal guards
  return null;
},

Blocking navigation on a “have we restored the token?” ValueNotifier keeps redirects synchronous and deterministic — no redirect-then-redirect races.

Common pitfalls

Redirect loops. Always make sure the destination of a redirect is itself allowed. If /login is protected by the same guard you are redirecting to, you get a stack overflow or a silent no-op.

Forgetting refreshListenable. State changes inside AuthProvider are invisible to the router unless you expose a Listenable. Wrap your ChangeNotifier or use a Listenable.merge.

Redirecting on every build. go_router deduplicates identical redirects, but constructing new closures or string locations on each call can cause extra work. Build the redirect target from stable values.

Ignoring query parameters. state.matchedLocation excludes the query string, state.uri.toString() includes it. Choose deliberately when composing redirect targets, or you will silently drop ?from= params.

Deep links into protected routes. Test cold-start deep links (myapp://settings or https://app.example.com/settings) while logged out — this is where most guard bugs surface, because the initial route resolution happens before your UI ever renders.

Debugging redirects

Print the pair of from/to locations while developing:

redirect: (context, state) {
  final next = computeRedirect(context, state);
  if (next != null && next != state.uri.toString()) {
    debugPrint('redirect: ${state.uri} -> $next');
  }
  return next;
},

The from value on /login plus the to value tells you exactly which rule fired. go_router also ships an error handling topic for unmatched routes — pair it with redirect logging and you can trace any navigation through your guard chain.

Putting it together

A production guard stack usually looks like this:

redirect: (context, state) {
  final uri = state.uri;
  final path = uri.path;

  if (bootstrap.value != BootstrapState.ready) return '/splash';

  final anonPaths = {'/login', '/reset-password'};
  final isAnon = anonPaths.contains(path);

  if (!auth.isLoggedIn && !isAnon) {
    return '/login?from=${Uri.encodeComponent(uri.toString())}';
  }
  if (auth.isLoggedIn && isAnon) {
    return uri.queryParameters['from'] ?? '/home';
  }
  if (auth.isLoggedIn && path == '/home' && !auth.onboarded) {
    return '/onboarding';
  }
  return null;
},

Order the rules from most general to most specific, keep each one a pure function of state plus your app state, and let refreshListenable do the reacting.

Wrapping up

Redirect guards turn navigation policy into data instead of scattered if (loggedIn) checks in build() methods. Define the rule once, point refreshListenable at your auth state, and every entry point — tabs, deep links, web URLs, notification taps — routes through the same logic.

References