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.