SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/IDOR: Missing Ownership Check
CWE-639High severitySupabaseNext.js

IDOR: Missing Ownership Check

◐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.

The vulnerable patterns and their fixes

getInvoice returns another user invoice (admin client, no ownership filter)

✗Vulnerablets
// 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.

↓the fix
✓Securets
// 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.

deleteProject destroys any project by id (admin client, no verification)

✗Vulnerablets
// 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.

↓the fix
✓Securets
// 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.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

  1. The attacker authenticates as user A and notices the app calls a Server Action such as getInvoice with an id parameter.
  2. The Server Action uses createAdminClient() (service_role) and queries invoices where id equals the supplied value. No ownership filter is applied.
  3. The attacker replaces their own invoice id with any other UUID. The query returns the target row because service_role ignores RLS.
  4. For a mutation like deleteProject, the attacker supplies a victim project id. The DELETE executes without error, destroying data they do not own.
  5. UUIDs are not sequentially guessable, but they leak through other endpoints, logs, shared links, and error messages, which makes the attack realistic rather than theoretical.

How to find it in your code

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.

Common mistakes

  • 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.

Does SecureStartKit prevent this?

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→

Frequently asked questions

When should I use createAdminClient() versus the user-scoped client?
Use the user-scoped client for any query whose result should be filtered to the current user; RLS then enforces ownership automatically. Reserve createAdminClient() for operations that genuinely require elevated access: Stripe webhook handlers, admin dashboards, background jobs. Every admin-client query on user-owned data needs an explicit ownership filter.
Does enabling RLS on a table protect it when I use the admin client?
No. service_role is explicitly exempt from RLS. RLS policies protect the anon and authenticated roles. If you query with service_role, every RLS policy on the table is skipped.
Is it enough to validate the id format with Zod to prevent IDOR?
No. Zod validates shape (is this a valid UUID), not ownership (does this UUID belong to the caller). Input validation and authorization are separate concerns. You need both.
How do I handle multi-tenant scenarios where a user belongs to an organization?
Replace the per-user ownership filter with a check that the record org_id is in the set of orgs the caller belongs to (a join or subquery on the membership table). The principle is the same: the query must verify the caller has a relationship to the row, not just that they supplied a valid id.

References

  • CWE-639: Authorization Bypass Through User-Controlled Key ↗
  • OWASP IDOR Prevention Cheat Sheet ↗

Related weaknesses

  • Missing or Disabled RLS PolicyA table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.
  • Exposed Supabase service_role KeyThe service_role key appears in browser DevTools under the Network tab or Sources panel, is readable in your built JavaScript bundle, is committed to a git repository, or is returned inside an API response body.
  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.

Defined terms

  • IDOR
  • Row Level Security
  • Backend-Only Data Access

Go deeper

  • Multi-Tenancy and RBAC in Supabase: The Secure Pattern
  • RLS Policy Generator

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns