On this page
  1. Step 1: List every resource and the actions on it
  2. Step 2: Model ownership before roles
  3. Step 3: Put the ownership condition in the query
  4. Step 4: Add roles for the cases ownership can’t express
  5. Step 5: Scope every table by tenant if you’re multi-tenant
  6. Step 6: Make elevated access explicit, temporary, and logged
  7. Step 7: Deny by default, and make the safe path the easy one
  8. Step 8: Verify per feature, with two accounts
  9. Checklist
  10. FAQ
    1. Should I use roles or per-object permissions?
    2. Where should the authorization check live?
    3. How do I handle admin and support access?
    4. How do I test that authorization actually works?
  11. Related topics
  12. Sources
tutorial

How to Design Authorization for Your App (So Users Can't Reach Each Other's Data)

Ownership, roles, tenants, and admin access — how to design authorization that lives in the query, denies by default, and survives the endpoint someone forgets to protect.

Quick answer

  • Authorization design is mostly one decision: does the check live in the query, or in code that has to remember to run?
  • Model ownership first, roles second. Ownership covers “is this row theirs”; roles cover “what kind of user is this.”
  • Put the ownership condition in the WHERE clause so an unauthorized record can never be returned.
  • Multi-tenant apps need a tenant column on every table and a filter on every query — including the ones written by an assistant later.
  • Admin access should be assumed deliberately and logged, not granted implicitly by a role.
  • Verify per feature by requesting someone else’s resource with your own session; the endpoint that returns data is the bug.

Step 1: List every resource and the actions on it

Write down each thing your app stores and what can be done to it. For a project tracker that’s projects (read, create, update, delete, archive) and tasks (read, create, update, assign, delete). Include the actions you haven’t built yet — the list is the specification for what must be checked, and an action missing from it is an action nobody will guard.

Then mark each row with the scope it needs: per-object (this exact record belongs to someone), per-role (only administrators may do this), or both. Most rows are per-object, which is why ownership — not roles — is the primary design problem.

What success looks like: a table of resources and actions where every action has a named scope, and no action is left unclassified.


Step 2: Model ownership before roles

Ownership is the rule “this record belongs to this user,” and in generated apps it’s the rule that’s missing. Express it as a column on the row:

CREATE TABLE projects (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  owner_id   bigint      NOT NULL REFERENCES users (id),
  name       text        NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX projects_owner_idx ON projects (owner_id);

Ownership comes in two shapes worth distinguishing. Single owner — one user is responsible and only they (plus admins) may modify it. Shared access — several users may act on it, which needs a membership table rather than an owner column:

CREATE TABLE project_members (
  project_id bigint NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
  user_id    bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  role       text   NOT NULL DEFAULT 'viewer'
             CHECK (role IN ('viewer', 'editor', 'admin')),
  PRIMARY KEY (project_id, user_id)
);

If your app has any sharing or collaboration, this table is the design decision — a single owner_id cannot express “this teammate may edit.”

What success looks like: every user-specific table has either an owner column or a membership table, and you know which shape each one uses.


Step 3: Put the ownership condition in the query

This is the step that decides whether your authorization works. Compare the two versions of the same endpoint:

-- Fragile: the check is a line of code someone must remember to write.
SELECT * FROM projects WHERE id = $1;
-- ...then in application code: raise 403 if project.owner_id != session.user_id

-- Durable: an unauthorized row cannot be returned, because it can't be selected.
SELECT id, name, created_at
FROM projects
WHERE id = $1 AND owner_id = $2;

The second version has no “forgot the check” failure mode, because the check is the query. It also composes with everything else — list endpoints, updates, deletes — as long as every statement carries the same predicate:

UPDATE projects SET name = $3 WHERE id = $1 AND owner_id = $2;
DELETE FROM projects    WHERE id = $1 AND owner_id = $2;

Note that UPDATE and DELETE need it just as much as SELECT. An unguarded delete is how one user destroys another’s data, and it produces no error your logs will show.

Return 404 rather than 403 when a record exists but isn’t theirs — the difference tells an attacker which IDs are real.

What success looks like: grep your data access layer for reads and writes on user-specific tables, and confirm each one carries the ownership predicate.


Step 4: Add roles for the cases ownership can’t express

Roles handle “only administrators may refund an order” — actions where the question isn’t whose row it is. Store them as data rather than as conditions scattered through the code:

CREATE TABLE roles (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE       -- 'admin', 'editor', 'viewer'
);

CREATE TABLE user_roles (
  user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, role_id)
);

Two rules keep this maintainable. Give a user a role, not a permission set, so an authorization decision reads as one comparison. And don’t scatter if user.role == 'admin' through the codebase — centralize the mapping from role to capability in one place so changing what an admin can do is a single edit.

Role-based access control is the right default. Attribute-based models — decisions computed from properties of the user, the resource, and the environment — are worth reaching for only when the policy is genuinely conditional (approval limits by region, access by department), because they’re harder to reason about and to audit.

What success looks like: every role check in the codebase resolves through one module, and adding a capability doesn’t require searching for scattered string comparisons.


Step 5: Scope every table by tenant if you’re multi-tenant

If one deployment serves multiple organizations, tenant isolation is an authorization rule that must hold on every query forever — including the ones written next month by an assistant that can’t see the convention. The structural fix is a tenant column on every tenant-owned table, default-deny enforcement at the data layer, and row-level security where the database supports it:

ALTER TABLE projects ADD COLUMN tenant_id bigint NOT NULL REFERENCES tenants (id);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY projects_tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::bigint);

Where row-level security isn’t available, the equivalent is a data access layer that refuses to build a query without a tenant predicate — so a developer, or an assistant, cannot write the leaky version by accident.

What success looks like: the same record requested from two different tenants returns data for one and 404 for the other, and there is no query path that omits the tenant filter.


Step 6: Make elevated access explicit, temporary, and logged

Administrators and support staff need access customer users don’t have, and that’s where the worst incidents originate — not from a missing check, but from a check that grants everything to a role nobody audits.

Three practices cover most of it. Make elevation deliberate: an admin acts with normal privileges until they explicitly assume an elevated mode. Make it scoped to a reason: support sees a specific customer’s account because a ticket says so, not because they’re on the support team. Make it logged: an append-only audit table recording who acted, on which record, and why.

CREATE TABLE audit_log (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  actor_id    bigint      NOT NULL REFERENCES users (id),
  action      text        NOT NULL,
  resource    text        NOT NULL,
  resource_id bigint      NOT NULL,
  reason      text,
  created_at  timestamptz NOT NULL DEFAULT now()
);

What success looks like: you can answer “who viewed this customer’s data last month, and why” from the audit table, and no role grants silent access to other tenants’ records.


Step 7: Deny by default, and make the safe path the easy one

Deny-by-default means an action with no matching permission is refused, rather than allowed. It’s implemented by three things: a middleware or wrapper that every protected route must pass through, an unknown role or action resolving to “no,” and a test that asserts a new endpoint without a declared permission fails closed.

The complementary habit is making the safe path the path of least resistance. If the repository’s findById for a user-owned table already requires the current user as an argument, an assistant writing new code gets an ownership check for free. If it doesn’t, the assistant will write the unguarded version — not because it’s careless, but because that’s the obvious shape of the code around it.

What success looks like: deleting the permission declaration from a route produces a failing test rather than an open endpoint.


Step 8: Verify per feature, with two accounts

Authorization can’t be verified by reading one endpoint, because the flaw is always on the endpoint someone forgot. The check is short and mechanical, and it belongs in your definition of done:

  1. Sign in as account A. Create a record. Note its ID.
  2. Sign in as account B. Request that ID directly — read it, update it, delete it, list its children.
  3. Expect 404 or 403 for every one of those calls.

Do this when each feature ships, and re-run it when new endpoints appear. A scanner can find some of this class, but the manual pass catches the endpoint that returned 200 with the other account’s data, which is the only result that matters.


Where this bites vibecoders

Generated CRUD code authenticates the request and then fetches the record by whatever ID the URL contains. It’s not an oversight in the assistant’s reasoning — the feature works exactly as described, and “users can see their projects” is satisfied. What’s missing is a rule nobody stated: a project belongs to a person. Because the prompt described a screen and not a permission model, ownership exists only in the UI, where every link happens to point at your own records — so clicking through the app confirms it works. The gap is only visible when someone edits the ID in the URL. Two habits close it: state the ownership rule in the prompt — “a user may only read and modify records they own” — and ask for a design where the check is part of the query rather than a line in a handler. Adopting the first without the second still ships the flaw, because a rule the code can forget isn’t a rule the database enforces.

Checklist

  • Write the resource-and-action matrix before writing endpoints.
  • Give every user-owned table an owner column or a membership table.
  • Put the ownership predicate in the query for reads, updates, and deletes.
  • Return 404, not 403, when a record exists but belongs to someone else.
  • Store roles as data and resolve capabilities in one module.
  • Add a tenant column and enforce isolation at the data layer for multi-tenant apps.
  • Require deliberate elevation for admin and support access, and log every use.
  • Deny by default, and test that a route without a permission fails.
  • Make repository methods require the current user, so new code is safe by default.
  • Verify each feature with two accounts before calling it done.

FAQ

Should I use roles or per-object permissions?

Both, layered. Roles answer “what is this person’s job” and handle the broad cases — admin, editor, viewer. Per-object ownership answers “is this row theirs” and handles the cases roles can’t express. Starting with roles alone is the mistake, because most generated apps need ownership checks on nearly every record and roles on only a few.

Where should the authorization check live?

In the data access layer, next to the query, so the ownership condition is part of the fetch: select the row where the ID and the current user match. Checks in route handlers or the UI require every new endpoint to remember to call them, and the endpoint that forgets is the vulnerability. One query that can’t return someone else’s row is worth more than a check you have to remember.

How do I handle admin and support access?

Make it explicit and logged. A separate elevated role that must be deliberately assumed, every use recorded in an audit table with who and why, and no ability for support staff to read customer data silently. Admin access that arrives automatically with a certain role is indistinguishable from a bug when it’s abused.

How do I test that authorization actually works?

Create two accounts, then request the first account’s resources using the second account’s session, by ID, for every user-specific endpoint. You’re looking for 403 or 404 on all of them. Do it when each feature is built rather than once at the end, because the flaw appears on whichever endpoint was added without the check.


Sources

Share: