Auth Guards: Handling Authenticated, Public, and Unauthenticated
Redirect loops, users stuck on the wrong page, login forms for people already logged in. Getting guards right is mostly about one thing.
Introduction
Auth guards sit in front of routes and decide who can see what. They redirect unauthenticated users to login, or redirect authenticated users away from the login page, or send users in a special state (e.g. onboarding) to a specific flow. When the logic is wrong, you get redirect loops, users stuck on the wrong page, or logged-in users seeing a login form. This post is about structuring guards so that authenticated, unauthenticated, and "public" cases are handled clearly and consistently.
Three Cases to Handle
Most apps have at least three situations:
- Authenticated: The user is logged in. They can access the main app. They should not be sent to the login page (unless they are explicitly logging out).
- Unauthenticated: The user is not logged in. They can access public pages (marketing, login, signup). They should be redirected to login when they hit a protected route.
- Authenticated but in a special state: For example, they have not completed onboarding, or they must change their password. They might be allowed to access only the onboarding or password-change flow until that is done.
If the guard only checks "logged in or not," case 3 is easy to get wrong. You might send them to onboarding, but then the onboarding page might redirect them back to the app because it does not know they are "in onboarding." So you need a single, clear rule that both the guard and the target pages use: "user is in onboarding" means "redirect to onboarding and do not let them into the main app until done."
One Source of Truth for "Where Should This User Be?"
Define one place (e.g. a helper or a small module) that answers: "given this user and their state, which route should they be on right now?" For example: if not logged in and hitting a protected route, return "login." If logged in and not finished onboarding, return "onboarding." If logged in and finished onboarding, return "app." If logged in and hitting the login page, return "app" (they do not need to log in again).
Then the guard and any page that might redirect (e.g. the login page, the onboarding page) all call this helper. The guard: "if the user should be on route X and they are not, redirect to X." The login page: "if the user should not be on login (they are already logged in), redirect to app (or onboarding if needed)." The onboarding page: "if the user should not be on onboarding (they are done or not in that role), redirect to app." So everyone uses the same rule, and you cannot get into a loop where the guard sends to A and the page at A sends back to B.
Order of Checks and Loading State
Guards often run before the app has finished loading the user (e.g. from a session cookie or an API). If the guard runs with "no user" and redirects to login, but the user was actually logged in and the session was still loading, you just logged them out by mistake. So the guard should wait for the auth state to be resolved (loading complete) before redirecting. While loading, show a spinner or a neutral state; do not redirect on incomplete data.
Order of checks matters too. For example: first check "is auth loaded?" If not, wait. Then check "is the user in a special state (e.g. onboarding)?" If yes, send them to that flow unless they are already there. Then check "are they authenticated?" If not and the route is protected, send to login. Then check "are they on the login page but already authenticated?" If yes, send to app (or onboarding). Having a fixed order and a single helper keeps the behaviour predictable.
Public vs. Protected Routes
Mark routes as public (login, signup, marketing) or protected (dashboard, settings). The guard only redirects unauthenticated users when they hit a protected route. For public routes, the guard might still redirect authenticated users away (e.g. from login to app) but does not redirect unauthenticated users. That way "public" is explicit and you do not accidentally protect a page that should be open to everyone.
Summary
- Handle three cases: authenticated, unauthenticated, and "authenticated but in a special state" (e.g. onboarding). Use one helper that returns "where should this user be?" and have both the guard and the target pages use it so redirect logic cannot loop.
- Do not redirect on incomplete auth state. Wait until loading is done, then apply the rules.
- Mark routes as public or protected so the guard only enforces login for protected routes and can redirect logged-in users away from login when appropriate.
Have thoughts on this post or questions? Get in touch. More blog posts.