Skip to content

← Writing

engineering

Security and Row-Level Security: Can User A Read User B's Data?

· Jerwin Arnado ·

Part eight of the full-stack series. Auth answered who you are and what you can do in general. This post is about the bug that survives even good auth — the one where a logged-in, properly-authenticated user reads data that isn’t theirs by changing a number in the URL. It’s the single most common serious vulnerability in real web apps, and it has a name: broken object-level authorization (IDOR). Security is the whole layer; this is its sharpest edge.

Security is horizontal, not a checkbox

You don’t “add security” at the end like a feature. Every layer in this series has a security face: validated input, hashed passwords, secrets out of git, TLS in deployment. The mindset is defense in depth: assume any single control can fail, and make sure the next one catches it. The OWASP Top 10 is the field guide; the headline risks for a typical Laravel app:

Risk One-line defense
Injection (SQL, command) parameterized queries / the ORM, never string-built SQL
Broken access control (IDOR) scope every query to the current user — this post
XSS escape output by default (Blade {{ }}), sanitize rich input
CSRF framework token on every state-changing form
Sensitive data exposure TLS everywhere, hash/encrypt at rest, don’t log secrets

The bug: trusting the ID in the URL

Here’s how it ships. The endpoint authenticates the user, then fetches by ID:

// VULNERABLE: authenticated, but not authorized for THIS row
public function show($id)
{
    return new InvoiceResource(Invoice::findOrFail($id));
}

The user is logged in, so it feels secure. But nothing checks that invoice 4567 belongs to them. Change the URL from /invoices/4567 to /invoices/4568 and you’re reading a stranger’s invoice. No hacking tools required — just an incrementing integer.

The fix: scope every query to the owner

The data must be filtered by ownership in the query, so unowned rows are never even fetched. Two reliable patterns:

// 1. Scope the query through the relationship — the row simply can't be found
public function show(Request $request, $id)
{
    $invoice = $request->user()->invoices()->findOrFail($id);  // 404, not someone else's data
    return new InvoiceResource($invoice);
}

// 2. Or fetch, then authorize with a policy (from the auth post)
public function show(Invoice $invoice)
{
    $this->authorize('view', $invoice);   // PostPolicy-style ownership check → 403
    return new InvoiceResource($invoice);
}

The principle: never fetch by ID alone. Either constrain the query to the current user, or fetch and then run a policy that checks ownership. Both turn “load anything by guessing IDs” into “404/403 for anything not yours.”

Push it into the database too: real row-level security

App-layer scoping is necessary but it’s one forgotten where() from a leak. For multi-tenant systems, push the rule down a layer so the database enforces it regardless of which query runs:

  • Postgres RLS policies — the database attaches a WHERE tenant_id = current_setting(...) to every query on the table, automatically. A forgotten scope in app code can’t bypass it.
  • A global scope in Eloquent (poor-man’s RLS) — every query on the model gets the tenant filter unless explicitly removed:
protected static function booted(): void
{
    static::addGlobalScope('tenant', fn ($q) =>
        $q->where('tenant_id', auth()->user()?->tenant_id));
}

Defense in depth again: app scoping and a database-enforced rule, so one human mistake isn’t a breach.

Caveats and best practices

  • Use UUIDs/ULIDs for public IDs so they’re not guessable by incrementing. Helpful, but not a substitute for authorization — obscurity slows enumeration, it doesn’t stop it.
  • Test the negative case. Write a test where user B requests user A’s resource and asserts 403/404. The happy path passing tells you nothing about access control.
  • Log access-control failures and watch for spikes — they’re how an attack looks in your logs (someone walking IDs).
  • Keep dependencies patched. Most breaches ride a known CVE in an old package (Log4Shell, the xz backdoor). Automate the updates.

Conclusion

Mindset → defense in depth; security is every layer's job
IDOR    → never fetch by ID alone — scope to the owner
Fix     → user()->relation()->find(), or fetch + policy
Deepen  → DB-level RLS / global scope so one missed where() can't leak
Verify  → test that B cannot read A's data (assert 403/404)

Authentication gets you a logged-in user; authorization on every object is what keeps that user inside their own data. Scope your queries, enforce it in the database, and test the attack. Next: rate limiting — stopping one client from abusing even a perfectly secured endpoint.