KAHIBARO
Discord Login Register

14.6. Resource Ownership

Why Resource Ownership Matters

In many backend applications, data belongs to a specific user or group of users. A user should only be able to read, update, or delete the resources that they own, unless they have special permissions like admin access.

Examples of resources that usually have an owner:

If you only check that a user is authenticated, but you do not check ownership, any logged in user might be able to access or modify another user’s data. This is one of the most common and dangerous authorization bugs.

Rule: Authentication answers “Who are you?”.
Resource ownership answers “Which data is yours?”.
You must enforce both before giving access to user‑specific data.

In this chapter, we focus on modeling and enforcing resource ownership, not on how to log users in. Authentication is covered separately.

Modeling Ownership in the Database

You cannot reliably enforce ownership in your application code if the database does not model it clearly. The most common pattern is to store a reference from each resource to its owner.

User and Resource Tables

In a relational database like PostgreSQL, you typically have a users table and one or more tables that store user-owned resources.

Example: a simple notes application.

sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL
);
CREATE TABLE notes (
    id          BIGSERIAL PRIMARY KEY,
    owner_id    BIGINT NOT NULL REFERENCES users(id),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Here, notes.owner_id defines the owner of each note.

Rule: Every user-specific resource table should have a field that identifies who owns it, usually a foreign key to users(id).

Common Ownership Columns

Column nameMeaningTypical type
user_idResource belongs to a userBIGINT / UUID
owner_idGeneric owner (often a user)BIGINT / UUID
created_byWho created the recordBIGINT / UUID
updated_byWho last updated the recordBIGINT / UUID

You can choose names that fit your domain, but be consistent across tables so it is easy to know where the ownership is stored.

Direct vs Indirect Ownership

Ownership is sometimes direct, sometimes via related entities.

Direct ownership

The resource has a direct owner field.

sql
CREATE TABLE posts (
    id        BIGSERIAL PRIMARY KEY,
    author_id BIGINT NOT NULL REFERENCES users(id),
    title     TEXT NOT NULL,
    body      TEXT NOT NULL
);

An API request to get posts for the current user simply filters by author_id = current_user.id.

Indirect ownership

Some resources are owned “through” another resource.

Example: project and tasks.

sql
CREATE TABLE projects (
    id        BIGSERIAL PRIMARY KEY,
    owner_id  BIGINT NOT NULL REFERENCES users(id),
    name      TEXT NOT NULL
);
CREATE TABLE tasks (
    id          BIGSERIAL PRIMARY KEY,
    project_id  BIGINT NOT NULL REFERENCES projects(id),
    title       TEXT NOT NULL,
    done        BOOLEAN NOT NULL DEFAULT FALSE
);

A task is not owned directly by a user. It belongs to a project, and the project belongs to the user.

To verify task ownership, you must check the chain:

In SQL this looks like:

sql
SELECT t.*
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE t.id = :task_id AND p.owner_id = :current_user_id;

If no row is returned, the user does not own that task.

Rule: If ownership is indirect, always check the entire chain from resource to final owner.

Identifying the Current User

To enforce ownership, your backend must know who is making the request. This usually comes from your authentication system, for example through:

The details of authentication are described in other chapters, but here is the key idea: after authentication, you should have a current user identifier available in your backend logic.

Typical values:

Everything related to resource ownership compares this current_user.id to fields in your database models.

Enforcing Ownership in Code

Once the database models ownership, you must enforce it in your business logic and API endpoints.

Basic Patterns for Ownership Checks

There are two common ways to enforce ownership:

  1. Filter queries by owner, so you never see resources that do not belong to the user.
  2. Fetch the resource, then compare its owner to the current user, and reject if they differ.

Pattern 1: Filter by owner in the query

Example in SQL for listing notes of the current user:

sql
SELECT *
FROM notes
WHERE owner_id = :current_user_id;

Your API handler might do something like:

python
def list_my_notes(current_user_id: int, db):
    return db.query(Note).filter(Note.owner_id == current_user_id).all()

This approach is very safe for listing data, because the user never sees resources that are not theirs.

Pattern 2: Load, then compare owner

Example for updating a specific note:

python
def update_note(note_id: int, data, current_user_id: int, db):
    note = db.query(Note).filter(Note.id == note_id).first()
    if note is None:
        raise NotFoundError()
    if note.owner_id != current_user_id:
        raise ForbiddenError("You do not own this note")
    # Now it is safe to update
    note.title = data.title
    note.content = data.content
    db.commit()
    return note

This pattern is useful when you need to return different error messages, for example:

Where to Put Ownership Checks

To avoid duplicating code, centralize ownership checks:

Example in a service layer:

python
def get_user_note(note_id: int, current_user_id: int, db):
    return (
        db.query(Note)
        .filter(Note.id == note_id, Note.owner_id == current_user_id)
        .first()
    )

Then in your API handler you only call this function. If it returns None, you respond with 404 without exposing whether another user owns that note.

Choosing the Right HTTP Status Codes

When ownership checks fail, the client must receive a clear and safe response.

Typical status codes:

SituationStatus codeComment
Resource does not exist404“Not Found”
Resource exists but user does not own it403 or 404“Forbidden” or pretend it does not exist
User not authenticated401“Unauthorized” (really Unauthenticated)

Many APIs choose to always return 404 Not Found when a resource is not owned by the current user. This avoids leaking the existence of data.

Rule: For user-owned resources, it is often safer to return 404 for both “not found” and “not owned,” so attackers cannot discover other users’ data.

You can still log the real reason internally for debugging and security monitoring.

Ownership in Multi-User and Team Scenarios

Real applications often have more complex ownership rules than “user owns a record.”

Ownership vs Access

Ownership is about who primarily “owns” a resource. Access is about who can interact with it. They are related but different:

You might store both:

sql
CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    owner_id    BIGINT NOT NULL REFERENCES users(id),
    is_public   BOOLEAN NOT NULL DEFAULT FALSE
);

Access rules can then be:

Shared Resources

Shared resources can be modeled using a join table.

Example: users and projects, with memberships.

sql
CREATE TABLE projects (
    id          BIGSERIAL PRIMARY KEY,
    owner_id    BIGINT NOT NULL REFERENCES users(id),
    name        TEXT NOT NULL
);
CREATE TABLE project_members (
    user_id     BIGINT NOT NULL REFERENCES users(id),
    project_id  BIGINT NOT NULL REFERENCES projects(id),
    role        TEXT NOT NULL, -- 'owner', 'editor', 'viewer'
    PRIMARY KEY (user_id, project_id)
);

Now a project can have:

To check whether a user can access a project:

sql
SELECT 1
FROM project_members
WHERE project_id = :project_id
  AND user_id = :current_user_id;

Or include the owner as a member as well, which simplifies access checks.

Example in code:

python
def user_can_access_project(project_id: int, current_user_id: int, db):
    return (
        db.query(ProjectMember)
        .filter(
            ProjectMember.project_id == project_id,
            ProjectMember.user_id == current_user_id,
        )
        .first()
        is not None
    )

You can then extend this to check member role for more fine-grained permissions.

Ownership with Admins and Elevated Roles

Admins or moderators often need to access resources they do not own. You must be careful not to accidentally bypass ownership for normal users.

A simple pattern:

python
def can_access_resource(resource, current_user):
    if current_user.is_admin:
        return True
    return resource.owner_id == current_user.id

In an endpoint:

python
def get_note(note_id: int, current_user, db):
    note = db.query(Note).filter(Note.id == note_id).first()
    if note is None:
        raise NotFoundError()
    if not current_user.is_admin and note.owner_id != current_user.id:
        raise ForbiddenError()
    return note

You should also enforce audit logs for admin access, which is covered more in security and logging chapters.

Rule: Do not use “admin” checks as a shortcut. Always be explicit:
if current_user.is_admin: allow
else: check ownership or permissions.

Avoiding Common Ownership Bugs

Resource ownership bugs are subtle but dangerous. Here are patterns that often lead to vulnerabilities, with better alternatives.

Bug 1: Trusting Client-Provided User IDs

Bad design:

http
POST /notes
Content-Type: application/json
{
  "user_id": 123,
  "title": "Secret",
  "content": "..."
}

If your backend accepts user_id from the client and uses it as the owner, any logged in user can create notes for other users.

Correct approach:

Example in handler:

python
def create_note(input_data, current_user, db):
    note = Note(
        owner_id=current_user.id,
        title=input_data.title,
        content=input_data.content,
    )
    db.add(note)
    db.commit()
    return note

You can even omit owner_id from the request model entirely so clients cannot try to set it.

Rule: Never trust a user_id or owner_id sent by the client. Ownership must come from the authenticated user, not the request body.

Bug 2: Forgetting Ownership Checks on Some Endpoints

It is common to implement ownership checks for most endpoints and accidentally forget them on a new one.

Examples that are often missed:

To reduce risk:

Example of centralized filter:

python
def notes_for_user_query(db, current_user_id: int):
    return db.query(Note).filter(Note.owner_id == current_user_id)

All endpoints that query user notes should use this helper, rather than querying Note directly.

Bug 3: Leaking Existence of Foreign Data

Consider this naive handler:

python
def get_note(note_id: int, current_user, db):
    note = db.query(Note).filter(Note.id == note_id).first()
    if note is None:
        raise NotFoundError()
    if note.owner_id != current_user.id:
        raise ForbiddenError("You do not own this note")
    return note

An attacker can use the difference between 404 and 403 to discover which note IDs exist.

Safer version:

python
def get_note(note_id: int, current_user, db):
    note = (
        db.query(Note)
        .filter(Note.id == note_id, Note.owner_id == current_user.id)
        .first()
    )
    if note is None:
        # Either it does not exist or user does not own it
        raise NotFoundError()
    return note

Now both cases produce a 404, so you do not leak whether the resource exists for another user.

Ownership in Practice: Example API

Let us put the concepts together with a simple notes API, focusing only on ownership aspects. Assume:

Minimal Note Model

python
class Note(Base):
    __tablename__ = "notes"
    id = Column(Integer, primary_key=True)
    owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    title = Column(String, nullable=False)
    content = Column(Text, nullable=False)

Creating a Note (Ownership Assignment)

python
def create_note_handler(request, current_user, db):
    data = request.json()
    note = Note(
        owner_id=current_user.id,  # ownership from auth, not from client
        title=data["title"],
        content=data["content"],
    )
    db.add(note)
    db.commit()
    db.refresh(note)
    return note   # returned to the owner

The client does not send owner_id. It is always derived from the authenticated user.

Listing Notes (Ownership Filtering)

python
def list_notes_handler(current_user, db):
    notes = (
        db.query(Note)
        .filter(Note.owner_id == current_user.id)
        .order_by(Note.id.desc())
        .all()
    )
    return notes

This guarantees you only ever return notes that belong to the current user.

Getting a Single Note (Combined Check)

python
def get_note_handler(note_id: int, current_user, db):
    note = (
        db.query(Note)
        .filter(Note.id == note_id, Note.owner_id == current_user.id)
        .first()
    )
    if note is None:
        raise NotFoundError()  # 404 regardless of reason
    return note

Updating a Note

python
def update_note_handler(note_id: int, request, current_user, db):
    data = request.json()
    note = (
        db.query(Note)
        .filter(Note.id == note_id, Note.owner_id == current_user.id)
        .first()
    )
    if note is None:
        raise NotFoundError()
    note.title = data.get("title", note.title)
    note.content = data.get("content", note.content)
    db.commit()
    db.refresh(note)
    return note

Deleting a Note

python
def delete_note_handler(note_id: int, current_user, db):
    note = (
        db.query(Note)
        .filter(Note.id == note_id, Note.owner_id == current_user.id)
        .first()
    )
    if note is None:
        raise NotFoundError()
    db.delete(note)
    db.commit()
    return {}, 204

Across all operations, you see the same pattern:

Summary

Resource ownership is about linking data to the user or group that it belongs to and enforcing that link in every operation that touches that data.

Key ideas to remember:

By treating resource ownership as a first-class concept in your designs, you significantly reduce the risk of serious authorization vulnerabilities in your backend applications.

Views: 5

Comments

Please login to add a comment.

Don't have an account? Register now!