UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

500 lines (408 loc) 14.7 kB
--- name: router-core/auth-and-guards description: >- Route protection with beforeLoad, redirect()/throw redirect(), isRedirect helper, authenticated layout routes (_authenticated), non-redirect auth (inline login), RBAC with roles and permissions, auth provider integration (Auth0, Clerk, Supabase), router context for auth state. type: sub-skill library: tanstack-router library_version: '1.166.2' requires: - router-core - router-core/data-loading sources: - TanStack/router:docs/router/guide/authenticated-routes.md - TanStack/router:docs/router/how-to/setup-authentication.md - TanStack/router:docs/router/how-to/setup-auth-providers.md - TanStack/router:docs/router/how-to/setup-rbac.md --- # Auth and Guards > **This skill covers the routing side of auth.** For the **server-side primitives** — session cookies (`HttpOnly`/`Secure`/`SameSite`), `useSession`-style helpers, OAuth `state` + PKCE, password-reset enumeration defense, CSRF, rate limiting — see [start-core/auth-server-primitives](../../../../start-client-core/skills/start-core/auth-server-primitives/SKILL.md). The two skills are designed to be used together. > > **CRITICAL**: A route guard (`beforeLoad`) does NOT protect a `createServerFn` declared on that route. Server functions are RPC endpoints reachable by direct POST regardless of which route renders them. See "Route guards do not protect server functions" below. ## Setup Protect routes with `beforeLoad` + `redirect()` in a pathless layout route (`_authenticated`): ```tsx // src/routes/_authenticated.tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated')({ beforeLoad: ({ context, location }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login', search: { redirect: location.href, }, }) } }, // component defaults to Outlet — no need to declare it }) ``` Any route file placed under `src/routes/_authenticated/` is automatically protected: ```tsx // src/routes/_authenticated/dashboard.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/dashboard')({ component: DashboardComponent, }) function DashboardComponent() { const { auth } = Route.useRouteContext() return <div>Welcome, {auth.user?.username}</div> } ``` ## Core Patterns ### Router Context for Auth State Auth state flows into the router via `createRootRouteWithContext` and `RouterProvider`'s `context` prop: ```tsx // src/routes/__root.tsx import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' interface AuthState { isAuthenticated: boolean user: { id: string; username: string; email: string } | null login: (username: string, password: string) => Promise<void> logout: () => void } interface MyRouterContext { auth: AuthState } export const Route = createRootRouteWithContext<MyRouterContext>()({ component: () => <Outlet />, }) ``` ```tsx // src/router.tsx import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' export const router = createRouter({ routeTree, context: { auth: undefined!, // placeholder — filled by RouterProvider context prop }, }) declare module '@tanstack/react-router' { interface Register { router: typeof router } } ``` ```tsx // src/App.tsx import { RouterProvider } from '@tanstack/react-router' import { AuthProvider, useAuth } from './auth' import { router } from './router' function InnerApp() { const auth = useAuth() // context prop injects live auth state WITHOUT recreating the router return <RouterProvider router={router} context={{ auth }} /> } function App() { return ( <AuthProvider> <InnerApp /> </AuthProvider> ) } ``` The router is created once with a placeholder. `RouterProvider`'s `context` prop injects the live auth state on each render — this avoids recreating the router on auth changes (which would reset caches and rebuild the route tree). ### Redirect-Based Auth with Redirect-Back Save the current location in search params so you can redirect back after login: ```tsx // src/routes/_authenticated.tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated')({ beforeLoad: ({ context, location }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login', search: { redirect: location.href }, }) } }, }) ``` ```tsx // src/routes/login.tsx import { createFileRoute, redirect } from '@tanstack/react-router' import { useState, type FormEvent } from 'react' // Validate redirect target to prevent open redirect attacks function sanitizeRedirect(url: unknown): string { if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//')) { return '/' } return url } export const Route = createFileRoute('/login')({ validateSearch: (search) => ({ redirect: sanitizeRedirect(search.redirect), }), beforeLoad: ({ context, search }) => { if (context.auth.isAuthenticated) { throw redirect({ to: search.redirect }) } }, component: LoginComponent, }) function LoginComponent() { const { auth } = Route.useRouteContext() const search = Route.useSearch() const navigate = Route.useNavigate() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const handleSubmit = async (e: FormEvent) => { e.preventDefault() try { await auth.login(username, password) navigate({ to: search.redirect }) } catch { setError('Invalid credentials') } } return ( <form onSubmit={handleSubmit}> {error && <div>{error}</div>} <input value={username} onChange={(e) => setUsername(e.target.value)} /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <button type="submit">Sign In</button> </form> ) } ``` ### Non-Redirect Auth (Inline Login) Instead of redirecting, show a login form in place of the `Outlet`: ```tsx // src/routes/_authenticated.tsx import { createFileRoute, Outlet } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated')({ component: AuthenticatedLayout, }) function AuthenticatedLayout() { const { auth } = Route.useRouteContext() if (!auth.isAuthenticated) { return <LoginForm /> } return <Outlet /> } ``` This keeps the URL unchanged — the user stays on the same page and sees a login form instead of protected content. After authentication, `<Outlet />` renders and child routes appear. ### RBAC with Roles and Permissions Extend auth state with role/permission helpers, then check in `beforeLoad`: ```tsx // src/auth.tsx interface User { id: string username: string email: string roles: string[] permissions: string[] } interface AuthState { isAuthenticated: boolean user: User | null hasRole: (role: string) => boolean hasAnyRole: (roles: string[]) => boolean hasPermission: (permission: string) => boolean hasAnyPermission: (permissions: string[]) => boolean login: (username: string, password: string) => Promise<void> logout: () => void } ``` Admin-only layout route: ```tsx // src/routes/_authenticated/_admin.tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_admin')({ beforeLoad: ({ context, location }) => { if (!context.auth.hasRole('admin')) { throw redirect({ to: '/unauthorized', search: { redirect: location.href }, }) } }, }) ``` Multi-role access: ```tsx // src/routes/_authenticated/_moderator.tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_moderator')({ beforeLoad: ({ context, location }) => { if (!context.auth.hasAnyRole(['admin', 'moderator'])) { throw redirect({ to: '/unauthorized', search: { redirect: location.href }, }) } }, }) ``` Permission-based: ```tsx // src/routes/_authenticated/_users.tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_users')({ beforeLoad: ({ context, location }) => { if (!context.auth.hasAnyPermission(['users:read', 'users:write'])) { throw redirect({ to: '/unauthorized', search: { redirect: location.href }, }) } }, }) ``` Page-level permission check (nested under an already-role-protected layout): ```tsx // src/routes/_authenticated/_users/manage.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_users/manage')({ beforeLoad: ({ context }) => { if (!context.auth.hasPermission('users:write')) { throw new Error('Write permission required') } }, component: UserManagement, }) function UserManagement() { const { auth } = Route.useRouteContext() const canDelete = auth.hasPermission('users:delete') return ( <div> <h1>User Management</h1> {canDelete && <button>Delete User</button>} </div> ) } ``` ### Handling Auth Check Failures (isRedirect) When `beforeLoad` has a try/catch, redirects (which work by throwing) can get swallowed. Use `isRedirect` to re-throw: ```tsx import { createFileRoute, redirect, isRedirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated')({ beforeLoad: async ({ context, location }) => { try { const user = await verifySession(context.auth) if (!user) { throw redirect({ to: '/login', search: { redirect: location.href }, }) } return { user } } catch (error) { if (isRedirect(error)) throw error // re-throw redirect, don't swallow it // Actual error — redirect to login throw redirect({ to: '/login', search: { redirect: location.href }, }) } }, }) ``` ## Common Mistakes ### CRITICAL: Route guards do not protect server functions A `beforeLoad` redirect protects the **route's UI**, not the **server functions** declared on it. `createServerFn` produces an RPC endpoint reachable by direct POST regardless of which route renders the calling UI. An attacker doesn't have to load `/_authenticated/orders` — they can curl the RPC endpoint directly. ```tsx // WRONG — handler has no auth check; the route guard doesn't help import { createServerFn } from '@tanstack/react-start' import { createFileRoute, redirect } from '@tanstack/react-router' const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => { return db.orders.findMany() // ← anyone can hit the RPC }) export const Route = createFileRoute('/_authenticated/orders')({ beforeLoad: ({ context }) => { if (!context.auth.isAuthenticated) throw redirect({ to: '/login' }) }, loader: () => getMyOrders(), }) ``` ```tsx // CORRECT — auth enforced on the handler itself, via middleware import { createServerFn } from '@tanstack/react-start' import { authMiddleware } from '~/server/auth-middleware' const getMyOrders = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .handler(async ({ context }) => { return db.orders.findMany({ where: { userId: context.session.userId } }) }) ``` Rule of thumb: every `createServerFn` that touches user data needs `authMiddleware` (or an equivalent in-handler check). The route guard is for the page experience; the RPC guard is for the data. See [start-core/auth-server-primitives](../../../../start-client-core/skills/start-core/auth-server-primitives/SKILL.md) for the full session/middleware pattern. ### HIGH: Auth check in component instead of beforeLoad Component-level auth checks cause a **flash of protected content** before the redirect: ```tsx // WRONG — protected content renders briefly before redirect export const Route = createFileRoute('/_authenticated/dashboard')({ component: () => { const auth = useAuth() if (!auth.isAuthenticated) return <Navigate to="/login" /> return <Dashboard /> }, }) // CORRECT — beforeLoad runs before any rendering export const Route = createFileRoute('/_authenticated/dashboard')({ beforeLoad: ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login' }) } }, component: Dashboard, }) ``` `beforeLoad` runs before any component rendering and before the loader. It completely prevents the flash. ### HIGH: Not re-throwing redirects in try/catch `redirect()` works by throwing. If `beforeLoad` has a try/catch, the redirect gets swallowed: ```tsx // WRONG — redirect is caught and swallowed beforeLoad: async ({ context }) => { try { await validateSession(context.auth) } catch (e) { console.error(e) // swallows the redirect! } } // CORRECT — use isRedirect to distinguish intentional redirects from errors import { isRedirect } from '@tanstack/react-router' beforeLoad: async ({ context }) => { try { await validateSession(context.auth) } catch (e) { if (isRedirect(e)) throw e console.error(e) } } ``` ### MEDIUM: Conditionally rendering root route component The root route always renders regardless of auth state. You cannot conditionally render its component: ```tsx // WRONG — root route always renders, this doesn't protect anything export const Route = createRootRoute({ component: () => { if (!isAuthenticated()) return <Login /> return <Outlet /> }, }) // CORRECT — use a pathless layout route for auth boundaries // src/routes/_authenticated.tsx export const Route = createFileRoute('/_authenticated')({ beforeLoad: ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login' }) } }, }) ``` Place protected routes as children of the `_authenticated` layout route. Public routes (login, home, etc.) live outside it. --- ## Cross-References - See also: **router-core/data-loading/SKILL.md** — `beforeLoad` runs before `loader`; auth context flows into loader via route context - See also: **start-core/auth-server-primitives/SKILL.md** — server-side session cookies, OAuth state + PKCE, CSRF, password-reset hardening, rate limiting (the server half of authentication) - See also: **start-core/middleware/SKILL.md** — `authMiddleware` factory pattern for protecting individual `createServerFn` calls