14.6. Resource Ownership
Table of Contents
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:
- A to‑do app: tasks belong to a specific user
- A blog: posts and comments have authors
- An e‑commerce app: orders belong to the customer who placed them
- A file storage app: files and folders have an owner and possibly shared users
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.
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 name | Meaning | Typical type |
|---|---|---|
user_id | Resource belongs to a user | BIGINT / UUID |
owner_id | Generic owner (often a user) | BIGINT / UUID |
created_by | Who created the record | BIGINT / UUID |
updated_by | Who last updated the record | BIGINT / 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.
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.
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:
task.project_idreferencesprojects.idprojects.owner_idmust equalcurrent_user.id
In SQL this looks like:
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:
- A session cookie
- A JWT access token
- An API key mapped to a user
- OAuth 2.0 access tokens
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:
current_user_idinteger or UUID- a
current_userobject with anidfield and possibly roles
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:
- Filter queries by owner, so you never see resources that do not belong to the user.
- 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:
SELECT *
FROM notes
WHERE owner_id = :current_user_id;Your API handler might do something like:
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:
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 noteThis pattern is useful when you need to return different error messages, for example:
- 404 if the note does not exist
- 403 if the note exists but belongs to another user
Where to Put Ownership Checks
To avoid duplicating code, centralize ownership checks:
- In your repository layer: queries always filter by owner.
- In service functions: a function that updates a resource always validates ownership.
- In middleware or decorators: request handlers are wrapped by logic that validates ownership based on route parameters.
Example in a service layer:
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:
| Situation | Status code | Comment |
|---|---|---|
| Resource does not exist | 404 | “Not Found” |
| Resource exists but user does not own it | 403 or 404 | “Forbidden” or pretend it does not exist |
| User not authenticated | 401 | “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:
- Owner usually has full control.
- Other users may have read-only or limited rights.
- Admins may have special global permissions.
You might store both:
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:
- If
is_public = true, everyone can read. - Only
owner_idcan write, unless user is an admin. - Non-owners may get shared access via a separate table.
Shared Resources
Shared resources can be modeled using a join table.
Example: users and projects, with memberships.
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:
- A primary owner in
projects.owner_id. - Additional members and their roles in
project_members.
To check whether a user can access a project:
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:
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:
def can_access_resource(resource, current_user):
if current_user.is_admin:
return True
return resource.owner_id == current_user.idIn an endpoint:
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 noteYou 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:
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:
- Ignore
user_idfrom the client. - Use the authenticated
current_user.id.
Example in handler:
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:
- Export all data (for example
GET /notes/export) - Bulk update or delete (for example
DELETE /notes?ids=1,2,3) - Admin-like search (for example
GET /notes?query=...)
To reduce risk:
- Use centralized query functions that always filter by owner for user-specific data.
- Use integration tests that try to access another user’s resources.
Example of centralized filter:
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:
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 noteAn attacker can use the difference between 404 and 403 to discover which note IDs exist.
Safer version:
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 noteNow 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:
- Authentication is already implemented.
current_useris available in each handler.
Minimal Note Model
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)
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)
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 notesThis guarantees you only ever return notes that belong to the current user.
Getting a Single Note (Combined Check)
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 noteUpdating a Note
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 noteDeleting a Note
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 {}, 204Across all operations, you see the same pattern:
- Filter by
idandowner_id. - No client-controlled owner fields.
- Consistent error handling.
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:
- Model ownership explicitly in your database, typically via
owner_idoruser_id. - Use the authenticated user identity, not client-provided IDs, to assign ownership.
- Filter queries by owner or explicitly compare resource owner to
current_user.id. - Prefer returning 404 for non-owned resources to avoid information leaks.
- Centralize ownership checks and be consistent across all endpoints.
- Extend the idea to shared resources and team scenarios with membership tables and roles.
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
KAHIBARO