14.4. Protecting API Endpoints
Table of Contents
Why Protecting API Endpoints Matters
When you put an API on the internet, you are exposing entry points into your system. If you do not protect those endpoints correctly, anyone can:
- Read private data
- Change or delete data
- Impersonate other users
- Abuse resources, for example by creating millions of records
Protecting endpoints is about making sure that only the right people can do the right actions on the right resources.
In this chapter we focus on how to apply authentication and authorization at the endpoint level, how to structure checks, and how to avoid common mistakes. We will not re‑explain basic concepts like what a token is or how passwords are hashed, since those belong to the Authentication and general Authorization chapters.
Three Layers of Endpoint Protection
It helps to think of three layers that protect each request:
| Layer | Question answered | Example |
|---|---|---|
| Network & transport security | Can the request reach us safely? | HTTPS, firewalls, rate limiting |
| Authentication | Who is making this request? | Session cookie, JWT, API key |
| Authorization | Is this caller allowed to do this specific act? | Role check, permission check, ownership check |
In this chapter we focus on the authorization layer, but you always combine it with proper authentication and basic network protections.
Always enforce authorization at the backend. Never trust the frontend to hide or disable actions as your only protection.
A malicious client can always craft requests manually, no matter what your UI shows or hides.
Common Endpoint Protection Patterns
Most protected endpoints fall into one or more of these categories.
1. Authentication required
Only logged in users can access the endpoint.
Examples:
GET /mereturn profile of the currently authenticated userPOST /ordersplace an order for the logged in user
Implementation pattern:
- Check that a valid credential is present (session / JWT / access token).
- Reject if missing or invalid, usually with
$401$ Unauthorized.
Rule: Endpoints that change data should almost never be public. Require authentication by default, then explicitly mark and harden the few that are public.
2. Role based access
Only users with certain roles can access or perform actions.
Examples:
GET /admin/usersonly users with roleadminPOST /productsonly users with roleadminormanager
Typical logic:
if not user.is_authenticated:
401 Unauthorized
if "admin" not in user.roles:
403 Forbidden3. Permission based access
More fine grained than roles. User has specific permissions like "user.read", "product.create".
Examples:
GET /reports/salesrequires permission"reports.view_sales"DELETE /users/{id}requires both"user.read"and"user.delete"
This is similar to roles, but your check is based on a permission list, not a coarse role.
4. Ownership based access
Users can access only their own resources.
Examples:
GET /users/{id}user can only see their own profileGET /orders/{id}user can only see their own orders
Typical logic:
if not user.is_authenticated:
401 Unauthorized
order = get_order(order_id)
if order.user_id != user.id and "admin" not in user.roles:
403 ForbiddenThis chapter will combine these patterns in concrete examples.
Designing Access Rules for Endpoints
Start with a simple question for each endpoint: “Who should be allowed to do this?” and “Under what conditions?”
A helpful habit is to write a short policy rule for each endpoint in plain language.
Example for a blog API:
| Endpoint | Policy description |
|---|---|
GET /posts | Anyone can list published posts. |
GET /posts/{id} | Anyone can view a published post, only owner or admin can view drafts. |
POST /posts | Any authenticated user can create a post. |
PUT /posts/{id} | Only the author or an admin can update a post. |
DELETE /posts/{id} | Only the author or an admin can delete a post. |
GET /admin/users | Only admins can list users. |
Once you are clear on the rule, you can implement it consistently.
Rule: Write down access rules as explicit policies, even informally, before you implement them. This prevents accidental “allow all” logic.
Where to Enforce Authorization
You typically have 3 main places to enforce authorization:
| Place | Pros | Cons |
|---|---|---|
| Controller / endpoint | Very explicit, easy to understand per route | Can become repetitive and messy |
| Middleware / decorators | Reusable, keeps handlers cleaner | Harder to see logic at point of use if overused |
| Service / domain layer | Tied to business logic, hard to bypass | Needs discipline, sometimes feels less “HTTP‑ish” |
A practical approach is to combine them:
- Use middleware / decorators for generic checks like authentication required, role required.
- Use service layer for business specific checks like “user must be owner of this task”.
- Keep the endpoint code small and readable.
Example structure (Python / FastAPI style)
# auth_dependencies.py
def require_auth(user = Depends(get_current_user)):
return user
def require_admin(user = Depends(get_current_user)):
if "admin" not in user.roles:
raise HTTPException(status_code=403)
return user
# services/tasks.py
def assert_can_access_task(user, task):
if task.owner_id != user.id and "admin" not in user.roles:
raise ForbiddenError("Not allowed to access this task")
# endpoints.py
@router.get("/tasks/{task_id}")
def get_task(
task_id: int,
user = Depends(require_auth),
):
task = task_repo.get(task_id)
assert_can_access_task(user, task)
return taskThe endpoint is short, and the rules are centralized in helpers.
HTTP Status Codes for Protected Endpoints
Use consistent HTTP status codes when enforcing protection.
| Situation | Status code | Reason phrase |
|---|---|---|
| No authentication provided, but required | 401 | Unauthorized |
| Invalid or expired token / session | 401 | Unauthorized |
| Authenticated, but not allowed to access the resource | 403 | Forbidden |
| Resource is missing | 404 | Not Found |
Important nuance:
401 Unauthorizedusually means “you are not authenticated correctly”.403 Forbiddenusually means “we know who you are, but you cannot do this”.
Many APIs use 404 Not Found instead of 403 Forbidden for ownership based endpoints to avoid leaking information about resource existence.
Example:
GET /orders/123
- If order 123 exists but belongs to another user:
- Return 404 (pretend it does not exist for this user)
Rule: Prefer 404 over 403 for private resources that a user should not even know exist, to avoid information leaks.
Protecting Different Types of Endpoints
Public endpoints
These are intentionally available without authentication.
Examples:
POST /auth/registerPOST /auth/loginGET /healthGET /productsif your catalog is public
Hardening tips:
- Still validate all input.
- Still enforce rate limiting, especially for login or search.
- Do not reveal internal details in error messages.
Authenticated user endpoints
These require a logged in user, but not a specific role.
Examples:
GET /mePUT /meGET /orderslist current user orders
Typical pattern:
@router.get("/me")
def get_current_profile(user = Depends(require_auth)):
return userMake sure you always derive the user id from the authentication context, not from the client input.
Bad:
GET /users/{id}
# Client can set any id they want.Safer pattern for “my profile”:
GET /me
# Server picks the user from the auth token, not from path.Role restricted admin endpoints
These should be strongly protected and usually not exposed in the same way as public APIs.
Examples:
GET /admin/usersPOST /admin/productsDELETE /admin/users/{id}
Patterns:
- Use a distinct URL prefix like
/admin. - Require both authentication and an admin role or permission.
- In production, consider additional network constraints, for example VPN only, IP allowlist.
Example check:
@router.get("/admin/users")
def list_users(
user = Depends(require_admin),
):
return user_repo.list_all()Ownership based resource endpoints
Typical in applications where each user has their own items.
Examples:
GET /tasks/{id}PUT /tasks/{id}DELETE /tasks/{id}
Recommended steps:
- Authenticate user.
- Load resource by id.
- Check if resource belongs to user, or user is privileged.
- Return resource or perform action.
Example:
@router.get("/tasks/{task_id}")
def get_task(
task_id: int,
user = Depends(require_auth),
):
task = task_repo.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
if task.owner_id != user.id and "admin" not in user.roles:
# Option A: 404 to hide existence
raise HTTPException(status_code=404, detail="Task not found")
# Option B: 403 to be explicit
# raise HTTPException(status_code=403, detail="Not allowed")
return taskCommon Pitfalls and How to Avoid Them
1. Trusting client provided identifiers
Bad example:
POST /orders
Body: { "user_id": 123, "item_id": 999 }
The client could set any user_id, and create orders for someone else.
Better example:
POST /orders
Body: { "item_id": 999 }
# Server attaches the authenticated user_id.
Rule of thumb: Do not accept user_id or owner_id from the client for owned resources. Derive ownership from the authenticated user.
2. Forgetting authorization on “read only” endpoints
Reading can be as dangerous as writing. For example, reading someone’s personal data, email, or private messages is a serious breach.
Protect views of:
- User profiles with private info
- Internal logs
- Metrics dashboards
- Admin reports
3. Inconsistent checks between endpoints
Example mistake: GET /users/{id} checks that the requester is an admin, but DELETE /users/{id} forgets that check and only verifies authentication.
To avoid this:
- Reuse decorators / middleware or service functions.
- Write tests that verify protection for each sensitive endpoint.
4. Confusing authentication and authorization
Example mistake:
if not user:
# user is None or invalid
raise HTTPException(status_code=403)
Here you returned 403 Forbidden for an invalid login token. It should be 401 Unauthorized. Then 403 is used for authorization problems only.
Consistent semantics help users of your API and make debugging easier.
Protecting Endpoint Parameters
Endpoints often receive identifiers and filters in path parameters, query parameters, or request bodies. All of these can be manipulated by attackers, so treat them as untrusted input.
Example scenarios
- Path parameter misuse
GET /users/{id}
You must not assume that id is the id of the logged in user. Check explicitly that the authenticated user is allowed to see the user with that id.
- Query parameter misuse
GET /orders?user_id=123
An authenticated user might set user_id to another user value. Either:
- Ignore
user_idand always usecurrent_user.id. - Or require a role / permission to filter by other users.
Example:
@router.get("/orders")
def list_orders(
user = Depends(require_auth),
user_id: int | None = None
):
if user_id is not None and "admin" in user.roles:
return order_repo.list_for_user(user_id)
return order_repo.list_for_user(user.id)- Body parameter misuse
PUT /tasks/{id} with body { "title": "X", "owner_id": 5 }
Ignore or validate any sensitive fields like owner_id. Usually, do not allow client to change resource ownership unless you have very specific rules, and even then require extra authorization.
Protecting Collection vs Single Resource Endpoints
Collection endpoints list or search resources, for example GET /orders or GET /users.
Collection endpoints
Typical rules:
- Regular users can only see their own resources.
- Admins can see all resources.
Example:
@router.get("/tasks")
def list_tasks(
user = Depends(require_auth),
):
if "admin" in user.roles:
return task_repo.list_all()
return task_repo.list_for_owner(user.id)Rule: Filtering on the server by user id is not optional. Sending all tasks and relying on the frontend to hide some of them is insecure.
Single resource endpoints
For GET /tasks/{id}, PUT /tasks/{id}, DELETE /tasks/{id}, you must always:
- Load the task by id.
- Verify access.
- Then act.
Never skip the access check just because the collection endpoint already filters by user.
Combining Multiple Protection Rules
Often you need a mix of checks: authenticated, correct role, and correct ownership.
Example: comments on posts.
Rules:
- Any authenticated user can comment on any public post.
- Users can edit their own comments.
- Moderators can edit or delete any comments.
- Deleted posts hide their comments even from owners.
Implementation outline:
@router.post("/posts/{post_id}/comments")
def add_comment(
post_id: int,
body: CommentCreate,
user = Depends(require_auth),
):
post = post_repo.get(post_id)
if not post or not post.is_public:
raise HTTPException(status_code=404)
return comment_repo.create(
post_id=post.id,
author_id=user.id,
text=body.text,
)
@router.put("/comments/{comment_id}")
def update_comment(
comment_id: int,
body: CommentUpdate,
user = Depends(require_auth),
):
comment = comment_repo.get(comment_id)
if not comment:
raise HTTPException(status_code=404)
post = post_repo.get(comment.post_id)
if not post or post.deleted:
raise HTTPException(status_code=404)
if comment.author_id != user.id and "moderator" not in user.roles:
raise HTTPException(status_code=403)
return comment_repo.update(comment_id, body)This illustrates how business rules influence the protection logic.
Rate Limiting and Abuse Protection
Even if an endpoint has correct authentication and authorization, it can still be abused. For example, a user might:
- Call
POST /loginthousands of times to guess passwords. - Call
POST /ordersmany times to spam orders. - Call
GET /searchin a loop to scrape data.
To protect endpoints from abuse, combine authorization checks with rate limiting:
- Limit by IP address for public unauthenticated endpoints.
- Limit by user id or API key for authenticated endpoints.
- Limit sensitive actions more strictly.
Common practical settings:
| Endpoint type | Example limit |
|---|---|
Login (POST /auth/login) | 5 requests per minute per IP/user |
| Password reset | 3 per hour per user/email |
| Search | 60 requests per minute per user |
| Heavy reports | 10 requests per hour per user |
Rate limiting is usually implemented with a tool like Redis and middleware, not in each endpoint, but you should design which endpoints need what limits.
Hiding Sensitive Implementation Details
Error messages and endpoint behavior can leak information to attackers.
Avoid responses like:
{
"detail": "User with id 123 exists but you are not admin"
}Better responses:
- Generic messages:
"Not allowed"or"Resource not found". - Use
404to avoid confirming existence of private resources.
Also:
- Do not expose internal ids if not necessary. You can use random identifiers or slugs for URLs.
- Do not include stack traces or SQL errors in production responses.
Testing Endpoint Protection
To ensure your protection rules really work, you need tests that try to break your own API.
For each sensitive endpoint, test at least:
- Unauthenticated client.
- Authenticated user with correct role / ownership.
- Authenticated user with wrong role / ownership.
- Admin or elevated role user.
Example test cases for GET /tasks/{id}:
| Scenario | Expected result |
|---|---|
| No token | 401 Unauthorized |
| Token of user A, task of user A | 200 OK |
| Token of user A, task of user B | 404 Not Found or 403 Forbidden |
| Token of admin, task of any user | 200 OK |
Automated tests catch mistakes like:
- Removing a decorator accidentally.
- Changing route logic and skipping a check.
- Misconfiguring dependencies.
Summary
Protecting API endpoints is about consistently enforcing authentication and authorization rules at the boundary of your application.
Key practices:
- Require authentication by default for all modifying endpoints.
- Use clear policies per endpoint: who can do what, and why.
- Enforce role checks, permission checks, and ownership checks in reusable helpers.
- Never trust client provided user identifiers for authorization decisions.
- Use appropriate HTTP status codes:
401for auth problems,403/404for access problems. - Limit abuse with rate limiting, especially on public and sensitive endpoints.
- Test all important endpoints with multiple roles and scenarios.
If you design endpoint protection systematically and implement it in a structured way, you greatly reduce the risk of data leaks and unauthorized actions in your backend.
Views: 4
KAHIBARO