SecureStartKit defaults limit the blast radius, but createAdminClient() bypasses that protection.
Last reviewed June 13, 2026 by SecureStartKit Team
The short answer
Add an ownership filter (eq on user_id) to every Supabase query that takes a user-supplied id when using the admin (service_role) client, because that client bypasses Row Level Security entirely. Alternatively, switch to the user-scoped client so RLS enforces ownership automatically.
Where it shows up: A Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.
// actions/billing.ts
'use server'
import { createAdminClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth'
export async function getInvoice(invoiceId: string) {
await getSession() // confirms the caller is logged in, nothing more
const supabase = createAdminClient() // service_role: RLS is OFF
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', invoiceId) // any UUID works, no ownership check
.single()
if (error) throw new Error(error.message)
return data // may belong to a different user
}createAdminClient() uses the service_role key, so Supabase executes the query as a superuser. The only filter is on id. Any authenticated caller who supplies another user invoice id receives that row.
// actions/billing.ts
'use server'
import { createAdminClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth'
export async function getInvoice(invoiceId: string) {
const session = await getSession()
if (!session?.user) throw new Error('Unauthenticated')
const supabase = createAdminClient()
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', invoiceId)
.eq('user_id', session.user.id) // ownership filter
.single()
if (error || !data) throw new Error('Invoice not found')
return data
}Adding an ownership filter on user_id turns the query into an AND condition. If invoiceId belongs to another user, Supabase returns zero rows and the action throws, leaking nothing.
// actions/projects.ts
'use server'
import { createAdminClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth'
export async function deleteProject(projectId: string) {
await getSession() // confirms login only
const supabase = createAdminClient()
const { error } = await supabase
.from('projects')
.delete()
.eq('id', projectId) // deletes any project, regardless of owner
if (error) throw new Error(error.message)
}service_role bypasses RLS and the delete runs against any row matching the id. An attacker can destroy another team project with a single request.
// actions/projects.ts
'use server'
import { createAdminClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth'
export async function deleteProject(projectId: string) {
const session = await getSession()
if (!session?.user) throw new Error('Unauthenticated')
const supabase = createAdminClient()
const { error } = await supabase
.from('projects')
.delete()
.eq('id', projectId)
.eq('user_id', session.user.id) // ownership enforced in the mutation itself
if (error) throw new Error(error.message)
}Including the ownership filter in the DELETE itself (not only a pre-flight SELECT) closes the time-of-check/time-of-use gap. If the project does not belong to the caller, zero rows match and nothing is deleted.
Search the codebase for every call to createAdminClient() and inspect each Supabase query that follows it:
grep -rn "createAdminClient" ./actions ./app/api
For each result, check whether the query includes a filter on user_id (or an equivalent ownership column) alongside the user-supplied id. A query shaped as a lone id filter with no ownership filter is a candidate for IDOR.
Confirm the session is not merely verified for authentication but that the authenticated user id is actually used in the query filter. Confirming a session without using session.user.id in the query is the most common form of this bug.
Review the RLS policies on affected tables. If a table has no policy and your code relies solely on the admin client, every mutation is unrestricted.
Myth“UUIDs are random enough that guessing them is not a realistic attack.”
UUIDs are not secret. They appear in URLs, API responses, logs, browser history, and shared links. Treat any id that crosses a trust boundary as attacker-controlled.
Myth“Checking that the user is authenticated is the same as checking that they own the record.”
Authentication answers "who are you". Ownership answers "is this yours". Both checks are required. A valid session proves identity; it says nothing about the record owner.
Myth“Row Level Security covers everything, so I never need ownership filters in code.”
RLS only applies when using the user-scoped client. createAdminClient() uses service_role, which bypasses RLS completely. The moment you reach for the admin client, ownership enforcement becomes your responsibility in application code.
Myth“Putting the ownership check in the SELECT but not the DELETE is safe because the SELECT already confirmed ownership.”
A time-of-check/time-of-use race exists between the SELECT and the DELETE. Always include the ownership filter in the mutating query itself, not only in a pre-flight check.
The kit enforces backend-only data access and ships with Row Level Security enabled on all tables. When you use the user-scoped Supabase client, RLS enforces ownership automatically and IDOR is prevented at the database layer. The risk surface opens when you reach for createAdminClient() (required for some admin operations, webhooks, and background jobs): at that point RLS is off and the ownership check is entirely your responsibility. The default architecture points you the right way, but the admin client is readily available and the mistake is easy to make.
How the kit scopes data with RLS