Your /api route is open and you have not noticed
You built a quick endpoint to fetch rows for your own frontend. You forgot to ask who is calling it. Anyone can — and an agent will, at scale, without getting bored or asking permission.
September 2026 · Zoltra · No account needed to use this fix
The third seam in this little series is the one that feels most like a shrug, which is exactly why it stays open. You have a Route Handler at /api/waitlist or /api/users that returns rows. It was scaffolding, or a quick utility for your own dashboard, or a webhook you prototyped and kept. You never added an auth check because, well, the frontend calls it and it works. No one outside your team even knows it is there.
Except it is there, on the open internet, at a predictable path, returning data.
# You already know what an agent does with this curl -s https://example.com/api/waitlist | head -c 400 curl -s https://example.com/api/users?limit=100 | head -c 400
When that returns rows without a 401, you have handed the internet a read API for your database. Not through a misconfigured PostgREST grant — though we covered that last post — but through your own code. Your handler queried with a privileged key, skipped auth, and sent the result to whoever asked.
I am being specific here because the vague version of this advice is useless. People hear “secure your API” and think it means adding rate limiting or a CAPTCHA. Those help, but they do not fix the core thing: the handler did not check who the caller is before returning data.
How the handler ends up open
Most open routes I have seen were not designed to be open. They grew into it. Someone wrote a GET handler to fetch waitlist entries for an admin view that lived behind auth on the frontend page — but the handler itself had no auth. The frontend being gated gives a false sense of coverage. The browser route has auth; the API route does not. The agent does not use your browser route. It calls the API directly.
Another path: the handler was intentionally open during early development — you needed to hit it from a script without signing in — and the TODO to add auth got lost behind real deadlines. Or it was a POST route for collecting signups that also accidentally handles GET, and GET returns the list. Next.js Route Handlers export functions by HTTP method, so if you wrote export async function GET as scaffolding and forgot, it is live.
The service-role flavor is the worst variant. You create a Supabase client with SUPABASE_SERVICE_ROLE_KEY inside the handler so you can bypass RLS for a trusted operation. That is the right key for some jobs. But the moment you use it in a handler without checking the caller, RLS stops being relevant. The service role is the database superuser. Your policy on waitlist does not apply to it. The handler will return rows no matter what the table policy says — and it will do so for an anonymous caller over the public internet.
This is the thing I want people to sit with for a second. A lot of “secure your database” advice stops at RLS. RLS matters. But a privileged API route that does not check auth is a tunnel around every policy you just wrote. Fixing the database without fixing the route leaves the door open.
I have seen this exact shape on otherwise well-secured projects — perfect RLS everywhere, service role used “just for that one internal tool,” and that one tool is the API route the frontend hits on every page load. No one thinks of it as an exposure because the frontend is gated. But the API is not your frontend. It is a URL that answers to the world. ngl that disconnect between “my page is protected” and “my endpoint is wide open” is probably responsible for more quiet data leaks than any database misconfig.
The actual fix
One idea, applied consistently: every handler that returns private data must know who called it.
// app/api/waitlist/route.ts — the before
export async function GET() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // superuser — bypasses RLS
);
const { data } = await supabase.from("waitlist").select("*");
return Response.json(data); // no auth check — anyone gets rows
}
// The after — same behavior for the owner, 401 for everyone else
import { auth } from "@/lib/auth"; // WorkOS, Supabase auth, whatever you use
export async function GET(req: Request) {
const user = await auth.getUser(req);
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
// Now that you know who is calling, use a least-privilege client
const supabase = createClientForUser(user); // RLS applies, anon-like scope
const { data } = await supabase
.from("waitlist")
.select("*")
.eq("owner_id", user.id); // policy can enforce this too
return Response.json(data);
}That last line matters more than it looks. Even after you add auth, keep the data scope narrow.
- Prefer a per-user Supabase client that respects RLS over the service role. If you must use the service role for a trusted operation, scope the query explicitly — owner ID, team membership, accepted invite — rather than
select(*)on the whole table. - If the route is truly meant to be public — say a waitlist signup POST — handle only POST, validate the input tightly, and make sure GET returns nothing. Do not leave a GET that lists entries “because it is useful for debugging.”
- Revoke the anon grant behind the route as well. Auth in the handler and RLS on the table together gives you two gates instead of one. Either alone is weaker than both.
-- In the database, even for tables behind an API: no open grants REVOKE ALL ON waitlist FROM anon; -- Then an explicit policy for the narrowed client CREATE POLICY "owners can list own waitlist entries" ON waitlist FOR SELECT USING (auth.uid() = owner_id);
Rate limiting belongs here too, but as a second layer, not the fix. Put something in front of the handler — a middleware or a gateway rule — that caps anonymous calls. It will not stop an authenticated caller from abusing a loose scope, though. Auth first, scope second, rate limiting third.
How to know you are done
Hit your own deployed site the way an agent would. No cookies, no session, just curl:
# Should be 401, not rows
curl -s -D - https://example.com/api/waitlist | head -n 20
# Look for: HTTP/1.1 401 Unauthorized
# Body should be an error, not JSON rows
curl -s https://example.com/api/waitlist | head -c 300
# Expect: {"error":"Unauthorized"}
# The public POST still works if you intended it to
curl -s -X POST https://example.com/api/waitlist \
-H "content-type: application/json" \
-d '{"email":"test@example.com"}' | head -c 300
# But GET after must not list what you just inserted
curl -s https://example.com/api/waitlist | head -c 300
# Must still be: {"error":"Unauthorized"}If the first check returns rows, you are not done. If it returns 401 and your logged-in frontend still fetches the list successfully, you are. The distinction is important. “It returns 401” alone is not enough — it has to return 401 for the agent and still work for the owner. That is the whole point of auth. Make sure both sides are true.
Put one more thing in place that lasts longer than this afternoon: every new /api route in your project should start with an auth check and you should be able to delete it without questioning whether the endpoint was supposed to be open. Make unauthenticated handlers the exception that requires an explicit comment and review, not the default that slips through.
Honestly, this is the fix I most want people to just go do right now — not because it is the most severe, but because it is the fastest to check and the easiest to misread as “handled.” RLS at least looks like a checkbox. An open route looks like nothing. You just curl it and it answers, and you think “well, that data is probably public anyway.” Maybe it is. But the habit of every GET returning rows for anyone is the thing an agent relies on. It does not need your docs. It just needs one path that forgot to ask who is calling.
You can still ship this fix yourself. With GitHub connected, Zoltra can also prepare the repair as a pull request, leave it for your review, and verify the live result after deployment. Curl your own site right now. If the path answers without auth, you have a short, satisfying commit to make. And unlike the env leak or the missing RLS policy, the feedback is instant — a single request tells you if the seam is closed.
Series: Stop shipping env keys to the browser · Lock every table with RLS · Close your open API routes (you are here)