AI Guards Your Dashboard and Forgets Your API
AI tools gate the page with a client-side redirect and ship the route handler with no auth. Here's the exploit, the PostgREST and JWT mechanics behind it, and the server-side fix.
We audit apps built with Lovable, Bolt, and Cursor, and the same shape turns up in almost every one: a dashboard that redirects to /login when you’re signed out, in front of a route that returns the same data to anyone who calls it. The redirect runs in the browser. The route authenticates nobody. In the worse cases it also queries with a service-role key, so even Row Level Security can’t catch what the handler never checks. We call the browser-side check the cosmetic guard, and the split that strands it there the page-vs-API split. Same class of defect as the unsigned-webhook default, RLS shipped disabled, and public S3 buckets: a trust boundary drawn one HTTP layer from where the data is read.
The page guard is a render decision, not a trust boundary
Prompt an AI tool for “a dashboard only logged-in users can see” and the guard lands on the component:
// app/dashboard/page.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/use-auth'
export default function DashboardPage() {
const { user, loading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!loading && !user) {
router.push('/login')
}
}, [user, loading, router])
if (loading) return <Spinner />
if (!user) return null
return <Dashboard userId={user.id} />
}
Three properties:
- It runs in the client runtime. The server never evaluates it. The check has no bearing on what any HTTP endpoint returns.
- The
if (!user) return nullline does stop the protected component from rendering, so there’s no flash of content. But that’s a render decision in the browser too. It controls the DOM, not the response any endpoint returns. - It gates a React subtree. The data under it is fetched over HTTP from a handler that makes its own, independent authorization decision. That handler is where the only decision that matters lives.
The route handler authenticates nobody
Here’s the endpoint the dashboard calls, generated in a separate prompt:
// app/api/projects/[id]/route.ts
import { NextResponse } from 'next/server'
import { createAdminClient } from '@/lib/supabase/admin' // service_role key
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const supabase = createAdminClient()
const { data: project } = await supabase
.from('projects')
.select('*')
.eq('id', params.id)
.single()
return NextResponse.json(project)
}
No getUser(), no 401, no ownership predicate. And it uses the service-role client, which is the part that turns a missing check into a full read primitive.
A Supabase access token is a JWT. PostgREST reads its role claim and runs SET LOCAL ROLE to that Postgres role before executing your query; auth.uid() inside a policy resolves to current_setting('request.jwt.claims', true)::json ->> 'sub'. Your RLS policies are written for the authenticated and anon roles, typically USING (auth.uid() = owner_id). The service_role Postgres role is granted BYPASSRLS. So a query issued with the service key skips every policy, including the ownership predicate you were relying on. The admin client is the correct tool for genuinely trusted work (a cron job, a webhook whose origin you already verified). On a route a browser reaches, it converts “RLS protects this table” into “nothing does.”
So even if you fixed your RLS after our last post, this route walks around it by design.
Exploiting it is a GET
The page guard is never in the path. The attacker doesn’t load the page; they call what the page would have called:
# No cookie, no Authorization header. Just the id.
curl https://yourapp.com/api/projects/8f2a1c7e-4b90-4f1e-9c2d-a1b2c3d4e5f6
# 200, full project row
Blast radius is a function of your id type. A UUIDv4 carries 122 bits of entropy, so the table isn’t enumerable. That doesn’t make the ids secret. They leak constantly: in Referer headers to third parties, in shared links, in error payloads, in a support screenshot, in some other endpoint that returns ids unfiltered. Any leaked id is one unauthenticated GET away from the row. If the column is a bigserial, skip all of that and scrape the table:
for id in $(seq 1 100000); do curl -s "https://yourapp.com/api/projects/$id"; done
We’ve reproduced this on reference apps built with each of Lovable, Bolt, and Cursor. The page redirects, the handler serves. Every time.
Why the model puts the guard on the page
The model does not lack knowledge of auth. The problem is where the requirement sits in the prompt sequence. “Make the dashboard only visible to logged-in users” names the page as its subject, so the redirect lands on the component. A later, separate turn, “add an endpoint to fetch a project”, carries no actor and no policy in its context, so the model emits exactly a fetch-by-id. The two requirements never co-occur in one prompt, so the authorization that belongs at the seam between them is authored by neither.
That seam is the page-vs-API split. Authorization is a cross-cutting concern; an LLM emits one file per turn. The guard ends up on the surface you described and not on the read path you didn’t. Prompting “and check the user owns it” sometimes patches the one route in view, often on the admin client, where a single dropped .eq('owner_id', user.id) reopens it and nothing in the type system notices.
The fix is server-side identity, then ownership
// app/api/projects/[id]/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' // request-scoped, carries the caller's JWT
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: project, error } = await supabase
.from('projects')
.select('*')
.eq('id', params.id)
.eq('owner_id', user.id) // authorization, in the query
.single()
if (error || !project) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(project)
}
Four changes:
- Drop the admin client. The request-scoped client forwards the caller’s JWT as the
authenticatedrole, so RLS applies and the service-role bypass is gone. getUser()resolves identity and returns 401 when there’s none. Authentication, the who..eq('owner_id', user.id)resolves permission. Authorization, the what. With RLS also enforcing it at the row level this is belt and suspenders; without RLS it’s the only thing between a caller and another tenant’s row. Either way it lives in the query, where the read happens.- 404, not 403, on the ownership miss. Don’t confirm a row exists to someone who doesn’t own it.
getUser versus getSession, at the token level
This is where “I added auth” still ships the bug.
getSession() reads the JWT from the cookie or local storage, base64-decodes the payload, checks exp, and returns it. No network call, so it never asks the auth server whether the token is still good. It knows the token is well-formed and unexpired, and nothing past that. A token that belongs to a user who signed out everywhere, got deleted, or got banned still satisfies getSession(), because revocation lives on the server it didn’t call.
getUser() sends GET /auth/v1/user with the token. GoTrue verifies the signature against the project’s JWT secret (HS256, or the active asymmetric key on newer projects) and confirms the user record still exists and is permitted. That round trip is the revalidation, and it’s the reason Supabase’s own guidance is to never trust getSession() in server code. The cost is one request to your auth host per call; if it shows up in p99, cache the result briefly behind your own key, not by reaching back for getSession().
Make a missing check fail to compile, not fail silently
One fixed route doesn’t fix the split. The split is per-handler, and the real incident is the thirtieth endpoint someone added without the four lines. Centralize identity so a route can’t read a user without having authenticated one:
// lib/with-auth.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import type { User } from '@supabase/supabase-js'
type Ctx = { params: Record<string, string> }
type AuthedHandler = (req: Request, ctx: Ctx & { user: User }) => Promise<Response>
export function withAuth(handler: AuthedHandler) {
return async (req: Request, ctx: Ctx) => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return handler(req, { ...ctx, user })
}
}
// app/api/projects/[id]/route.ts
export const GET = withAuth(async (_req, { params, user }) => {
// `user` is only reachable through withAuth; there is no other way to get it here
...
})
This doesn’t make forgetting impossible. It makes the authenticated path the only place user comes from and the path of least resistance, and it collapses thirty scattered getUser() calls into one you can actually review. The ownership predicate still has to be written per query; identity is what gets centralized.
The negative tests
The happy-path test (owner fetches own project, 200) is the one the model writes. The bug lives in the two it doesn’t:
# 1. No session -> 401
curl -i https://yourapp.com/api/projects/SOME_ID
# Expect 401
# 2. Authenticated as A, fetching B's row -> 404 (not 200, not the row)
curl -i https://yourapp.com/api/projects/USER_B_PROJECT_ID \
-H "Cookie: $USER_A_SESSION_COOKIE"
# Expect 404
First returns 200: the cosmetic guard is still your only guard. Second returns the row: you authenticated the caller without authorizing the access.
What this doesn’t cover
Identity plus ownership closes the read. It says nothing about write paths (POST/PATCH need the same predicate, plus field-level rules on what a caller may set), nothing about mass-assignment through select('*') returning columns the client shouldn’t see, and nothing about the getUser() round trip becoming a dependency on your auth host’s availability inside a hot path. And the owner_id model assumes single-tenant ownership; the moment you have shared projects or org roles, the predicate becomes a join against a membership table and RLS is the saner place to express it. We name those as separate findings.
The lesson
A redirect changes what renders. It does not change what the server returns. Authorization that isn’t expressed in the read path, in the query or the policy that runs the query, isn’t enforced anywhere a request can reach.
The same mistake scales past generated code. When Meta’s support AI handed over Instagram accounts, the guard was a conversation an attacker could talk through, and the authorization that mattered still wasn’t where the data got read.