Least privilege, every request
Give each role only what it needs; check access on every request.
OWASPowasp.org
OWASP Top 10
From security-best-practices: OWASP A01 Broken Access Control.
Change the ID in the URL
Signed in as Ava at Northwind. Try another invoice ID.
Signed in is enoughBad
.eq("id", id)Ownership in the queryGood
.eq("id", id).eq("company_id", user.companyId)export async function GET(req, { params }) {
const user = await getAuthenticatedUser();
if (!user) return new Response(null, { status: 401 });
const { data, error } = await db.from("invoices")
.select("*")
.eq("id", params.id)
.eq("company_id", user.companyId) // ownership lives in the query
.maybeSingle();
if (error) return new Response(null, { status: 500 });
// 404, not 403: don't confirm that someone else's ID exists
if (!data) return new Response(null, { status: 404 });
return Response.json(data);
}
Edit roles, then call the API
Tick permissions, pick a role, and Send hidden actions.
| Permission | Admin | Member | Viewer |
|---|---|---|---|
Hidden in the UI onlyBad
{can(role, a) && <Button />}Checked on the serverGood
authorize(user, action) // 403
Switch off one layer
Toggle layers and watch which attacks reach the data.
Request path
app.use(requireHttps); // HSTS, Secure cookies
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })); // 429
app.use(authenticate); // 401
app.use(validate(schema)); // Zod, unknown keys stripped
app.use(authorize); // role + ownership, 403
-- in the database
create policy tenant_isolation on invoices
using (company_id in (
select company_id from company_users where user_id = auth.uid()
));
Why it matters, pros and cons
Why it matters
Anything the client sends can be forged, so the server decides every time.
Pros
- A stolen viewer session can only read.
- Guessed IDs return nothing.
- One missed check is caught by the next layer.
Cons
- Every route needs the check, and a test.
- Too-fine permissions turn into support tickets.
- RLS and app checks can drift apart.