Every loader is a callable endpoint, not just page setup
The concrete launch risk in a Remix app is this: a loader that returns account data is its own HTTP endpoint, reachable by a direct request, even when the page around it is gated behind a login screen. A Remix loader security authentication check has to happen inside the loader itself, because hiding the link in the UI does nothing to the endpoint behind it.
Remix compiles each route's loader into a server endpoint that the framework calls on navigation and on client-side transitions. That same endpoint answers a plain GET. When the client requests a route's data, it appends a query parameter (historically _data, route-keyed in newer versions) so the server returns the loader's JSON instead of rendering HTML. Anyone can send that request. The loader runs and returns whatever it returns, with no UI in the path to stop it.
So "the dashboard is behind a login" describes the front end. It is observable that the route renders a login wall in a browser. What is only confirmed by checking is whether the loader behind that route actually requires a session before it hands back data.
- A loader is a public HTTP handler: it answers direct requests, not only in-app navigation.
- An action behaves the same way for writes: a POST to the route runs the action whether or not the form that normally triggers it was ever shown.
- This is an authorization and authentication gap on the endpoint, not a broken login form. The login page can work perfectly while the loader beside it never checks who is asking.
Why AI-built Remix apps skip the check inside loaders
The mental model that an AI assistant tends to produce is "the page is protected, so its data is protected." That is wrong in Remix specifically, because the loader is split out into its own endpoint. The generated code makes the app look finished while leaving the endpoint open.
A prompt like "build a dashboard route that shows the user's orders" produces a loader that queries orders and a component that renders them. It runs, the data appears, the demo works. The missing piece is the first lines of the loader that load the session, confirm a user exists, and redirect or throw if not. Nothing in a working render forces that piece to exist.
- A protected layout or a redirect in the root often hides the gap. If the redirect lives in a parent route's loader but a child loader queries data without its own check, the child endpoint still answers a direct request.
- Authentication and authorization get conflated. A loader might confirm someone is logged in (authentication) but still return another user's record because it never checks ownership (authorization). Both belong inside the loader.
- Fast deploy means the public URL exists before anyone sends a request straight to a loader. The app was only ever exercised through the UI, where the gap is invisible.
Send a direct request to your own loaders
You can confirm this on your own app without any attack. The point is to bypass the UI and hit the endpoint the way the framework does.
Open DevTools, the Network tab, and navigate the app while logged in. Watch the requests Remix makes for route data. You will see requests to your route paths carrying a data query parameter and returning JSON. Note the URL and the JSON shape for a route that should be private. Then, in a fresh private window with no session cookie, request that same URL directly. Do this only against your own app.
Test each route on its own. A redirect in a parent route tells you nothing about a child loader that queries its own data. The check is per endpoint, because the endpoints are independent.
- Suspicious result: the loader returns the same private JSON with no session cookie present. That means the endpoint serves data to an unauthenticated caller.
- Safer result: the request gets a redirect to the login route (a 302 to /login) or a 401 or 403, and no private data in the body. That is the loader refusing an anonymous caller.
- Check ownership too, not just login. Logged in as user A, request the loader for a record that belongs to user B by changing the id in the URL. If B's data comes back, the loader authenticates but does not authorize. Repeat for actions: send a POST to a route's action without a session and see whether the write is accepted.
Require the session inside every loader and action
The fix is to make authentication and authorization the first thing each loader and action does, before any query runs. The reliable place to land is a single helper, called at the top of every protected loader and action.
Write a requireUser helper that reads the session from the request, and if there is no valid user, throws a redirect to the login route. In Remix, throwing a redirect from a loader short-circuits it, so no data query runs for an anonymous caller. Call that helper as the first line of every loader and action that touches private data, and use the user it returns. Then scope the query to that user (filter by their id, or check ownership of the requested record) so authentication is followed by an authorization check.
- Put the session check first, before the database call, in each protected loader and action. A check that runs after the query has already leaked the work.
- Treat authentication and authorization as two steps: confirm who is asking, then confirm they are allowed this specific row.
- Do not rely on a parent route's loader to protect a child's data. Each loader that returns sensitive data needs its own check.
- Keep server secrets out of loader responses. A loader that returns a config object can accidentally include a key; send only what the page needs.
Adding requireUser to your loaders closes the unauthenticated-access path. It does not validate the contents of what an authorized user submits to an action, and it does not protect against an authenticated user requesting data they should not see unless you also add the per-record ownership check. Authentication, authorization, and input validation are three separate jobs.
Where VibeCodeGuard fits, and what it does not prove
VibeCodeGuard's Launch Check probes the public surface, so it can request a loader route directly with no session and observe what comes back. When a loader returns data to a caller with no auth header or session cookie, the scan flags that route as a callable unauthenticated public endpoint, with a severity and a direction for the fix. It is testing the thing an outside request can actually reach, the same way you would in the manual check above.
What it does not do is read your route code or your session logic. It cannot confirm that a loader which looks protected also scopes data to the right user, because per-record authorization is business logic that lives inside your loader. Deciding which user may see which record, and writing the ownership checks, is work you do in the code and should pair with manual review of your auth model.
Next step: list your routes that return private data, and for each one send a direct request to its loader endpoint with no session cookie. Add the session check to any that answer, then run a focused Launch Check against the public URL before sharing it, so an open loader shows up in your scan and not in someone else's traffic.
A clean Launch Check does not prove every loader is correctly scoped. The scan sees what an unauthenticated request can pull from the public surface; it does not verify that an authenticated user is blocked from another user's data. Treat a loader that returns private data without a session as a launch blocker, and treat a clean result as one signal among several, not a guarantee.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.