KAHIBARO
Discord Login Register

14.4. Protecting API Endpoints

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:

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:

LayerQuestion answeredExample
Network & transport securityCan the request reach us safely?HTTPS, firewalls, rate limiting
AuthenticationWho is making this request?Session cookie, JWT, API key
AuthorizationIs 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:

Implementation pattern:

  1. Check that a valid credential is present (session / JWT / access token).
  2. 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:

Typical logic:

text
if not user.is_authenticated:
    401 Unauthorized
if "admin" not in user.roles:
    403 Forbidden

3. Permission based access

More fine grained than roles. User has specific permissions like "user.read", "product.create".

Examples:

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:

Typical logic:

text
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 Forbidden

This 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:

EndpointPolicy description
GET /postsAnyone can list published posts.
GET /posts/{id}Anyone can view a published post, only owner or admin can view drafts.
POST /postsAny 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/usersOnly 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:

PlaceProsCons
Controller / endpointVery explicit, easy to understand per routeCan become repetitive and messy
Middleware / decoratorsReusable, keeps handlers cleanerHarder to see logic at point of use if overused
Service / domain layerTied to business logic, hard to bypassNeeds discipline, sometimes feels less “HTTP‑ish”

A practical approach is to combine them:

Example structure (Python / FastAPI style)

python
# 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 task

The endpoint is short, and the rules are centralized in helpers.


HTTP Status Codes for Protected Endpoints

Use consistent HTTP status codes when enforcing protection.

SituationStatus codeReason phrase
No authentication provided, but required401Unauthorized
Invalid or expired token / session401Unauthorized
Authenticated, but not allowed to access the resource403Forbidden
Resource is missing404Not Found

Important nuance:

Many APIs use 404 Not Found instead of 403 Forbidden for ownership based endpoints to avoid leaking information about resource existence.

Example:

text
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:

Hardening tips:

Authenticated user endpoints

These require a logged in user, but not a specific role.

Examples:

Typical pattern:

python
@router.get("/me")
def get_current_profile(user = Depends(require_auth)):
    return user

Make sure you always derive the user id from the authentication context, not from the client input.

Bad:

text
GET /users/{id}
# Client can set any id they want.

Safer pattern for “my profile”:

text
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:

Patterns:

Example check:

python
@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:

Recommended steps:

  1. Authenticate user.
  2. Load resource by id.
  3. Check if resource belongs to user, or user is privileged.
  4. Return resource or perform action.

Example:

python
@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 task

Common Pitfalls and How to Avoid Them

1. Trusting client provided identifiers

Bad example:

text
POST /orders
Body: { "user_id": 123, "item_id": 999 }

The client could set any user_id, and create orders for someone else.

Better example:

text
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:

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:

4. Confusing authentication and authorization

Example mistake:

python
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

  1. 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.

  1. Query parameter misuse

GET /orders?user_id=123

An authenticated user might set user_id to another user value. Either:

Example:

python
   @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)
  1. 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:

Example:

python
@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:

  1. Load the task by id.
  2. Verify access.
  3. 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:

Implementation outline:

python
@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:

To protect endpoints from abuse, combine authorization checks with rate limiting:

Common practical settings:

Endpoint typeExample limit
Login (POST /auth/login)5 requests per minute per IP/user
Password reset3 per hour per user/email
Search60 requests per minute per user
Heavy reports10 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:

json
{
  "detail": "User with id 123 exists but you are not admin"
}

Better responses:

Also:

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:

  1. Unauthenticated client.
  2. Authenticated user with correct role / ownership.
  3. Authenticated user with wrong role / ownership.
  4. Admin or elevated role user.

Example test cases for GET /tasks/{id}:

ScenarioExpected result
No token401 Unauthorized
Token of user A, task of user A200 OK
Token of user A, task of user B404 Not Found or 403 Forbidden
Token of admin, task of any user200 OK

Automated tests catch mistakes like:

Summary

Protecting API endpoints is about consistently enforcing authentication and authorization rules at the boundary of your application.

Key practices:

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

Comments

Please login to add a comment.

Don't have an account? Register now!