CodeDiag / Guides / React Router
Does a parent loader's auth check protect child routes in React Router?
No. React Router runs the loaders of matched routes in parallel, and a form submission runs the route's action first; loaders revalidate after it. A redirect in the parent's loader can't stop either one.
Check auth in every loader and action that needs it, or use route middleware, which runs before the matched loaders and actions. Remix v2 works the same way.
Why the parent's check doesn't cover its children
React Router's own docs say it directly: matched route loaders run in parallel, so a parent loader can't "protect" child route loaders or actions through an auth check.
Actions make it worse. A submission runs the action, and only when the action completes does React Router revalidate the loader data on the page. If the parent's loader redirects at that point, the action has already done its work.
What it looks like
export default [
route("dashboard", "routes/dashboard.tsx", [
route("projects/:id", "routes/project.tsx"),
]),
] satisfies RouteConfig;
export async function loader({ request }) {
const userId = await requireUserId(request); // redirects to /login
return { userId };
}
export async function action({ params }) {
await db.project.delete({ where: { id: params.id } });
return redirect("/dashboard");
}
Signed out, opening /dashboard/projects/42 sends you to the login page. Submitting to it deletes the project first: the action runs, and the redirect from the dashboard loader arrives afterwards.
Fix it
Check auth in the action itself
export async function action({ request, params }) {
const userId = await requireUserId(request);
await db.project.delete({ where: { id: params.id, ownerId: userId } });
return redirect("/dashboard");
}
Scoping the query to ownerId also answers the next question: not just "is someone signed in" but "may this user delete this project".
Or use route middleware on the parent
Route middleware on a protected route or a common ancestor runs before the matched loaders and actions. Set it up as React Router's middleware guide describes for your version, then:
export const middleware = [
async ({ request }) => {
await requireUserId(request);
},
];
Check every route at once
CodeDiag reads app/routes.ts or flat-route files, every action and loader, and route middleware. It calls out auth that only lives in a parent's loader. For the example above:
$ npx @sabahattink/codediag scan . ⚠ The action of /dashboard/projects/:id has no auth check of its own; the auth check in the loader of app/routes/dashboard.tsx does not run before the action [react-router-loader-auth-only] app/routes/project.tsx:4 → Check auth in this handler too, for example with await requireUserId(request), or move the check into middleware on the parent route
Either fix clears the finding. CodeDiag runs locally, needs no account, and stays offline with --no-audit. It is static analysis, so read the cited file before calling a route vulnerable.
Questions
Doesn't a redirect thrown by the parent loader stop the request?
It stops the page from rendering. By then the action has already run, and the other loaders ran alongside the parent's.
Does this apply to Remix v2?
Yes. Remix v2 loads data the same way: matched loaders in parallel, and the action before loaders revalidate.
Can clientLoader or clientAction enforce auth?
No. They run in the browser, so they are never a security boundary.
Where should the check live if many routes need it?
In route middleware on a common ancestor, or in a requireUser helper called at the top of every loader and action.
Sources
- React Router docs: Data loading, Authentication and authorization
- React Router docs: Actions
- React Router docs: Middleware, Authentication
Find every route anyone can call
One command for React Router, Next.js, SvelteKit, Express, Fastify, Hono, Elysia, NestJS, and tRPC. Your coding agent can run it too, through the MCP server or the Claude Code plugin.
$ npx @sabahattink/codediag scan .