Home / Courses / Security for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 4 of 8

Hands-On: Authorization, Access Control & Rate Limiting

Closing off Broken Object Level Authorization and Unrestricted Resource Consumption — verifying resource ownership on every endpoint, and Redis-backed rate limiting to prevent login brute force.

July 10, 2026

Our API now knows WHO is logged in (Part 3). But knowing identity alone isn't enough — we've never checked WHAT that identity is allowed to access. In this article we close off two threats at once from Part 2: Broken Object Level Authorization (BOLA) and Unrestricted Resource Consumption.

The get_current_user Dependency — Extracting Identity From the Token

Every endpoint that needs protection uses this dependency to find out who's making the request:

from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")


def get_current_user(
    token: str = Depends(oauth2_scheme),
    session: Session = Depends(get_session),
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )

    username = decode_access_token(token)
    if username is None:
        raise credentials_exception

    user = session.exec(select(User).where(User.username == username)).first()
    if user is None:
        raise credentials_exception

    return user

OAuth2PasswordBearer automatically reads the Authorization: Bearer <token> header from the request. If that header is missing entirely, FastAPI automatically rejects it with 401 BEFORE our function ever runs. If it's present but the token is invalid (expired, wrong signature, or the user no longer exists), we reject it manually via credentials_exception.

An endpoint that needs protection just adds one parameter:

@router.get("/api/tasks")
def list_tasks(current_user: User = Depends(get_current_user)):
    ...

Object Level Authorization — Checking Ownership on EVERY Endpoint

This is the core of it. The Task model now has an owner_id:

class Task(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str
    ...
    owner_id: int = Field(foreign_key="user.id", index=True)

And EVERY endpoint that accesses a specific task MUST verify ownership:

@router.get("/{task_id}", response_model=TaskRead)
def get_task(
    task_id: int,
    session: Session = Depends(get_session),
    current_user: User = Depends(get_current_user),
):
    task = session.get(Task, task_id)
    if not task or task.owner_id != current_user.id:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
    return task

For list_tasks, we don't even need a manual check — the filter already restricts the query from the start:

@router.get("", response_model=List[TaskRead])
def list_tasks(current_user: User = Depends(get_current_user), ...):
    query = select(Task).where(Task.owner_id == current_user.id)
    ...

An easy-to-miss security detail: notice we return 404 Not Found, NOT 403 Forbidden, for a task that exists but doesn't belong to that user. Why? If we returned 403, that indirectly confirms "a task with this ID DOES exist, you just aren't allowed to access it" — information an unauthorized party shouldn't have. With 404, from an attacker's point of view, there's no difference between "task doesn't exist" and "task exists but isn't yours" — both look identical.

Proving It With Two Real Users

# Alice creates a task
curl -X POST localhost:8000/api/tasks -H "Authorization: Bearer $ALICE_TOKEN" \
  -H "Content-Type: application/json" -d '{"title":"Alice'\''s secret task"}'
# {"id":1, "title":"Alice's secret task", ...}

# Bob tries to access task #1 (Alice's)
curl localhost:8000/api/tasks/1 -H "Authorization: Bearer $BOB_TOKEN"
# {"detail":"Task not found"}  ← 404, not a leak of Alice's data

# Bob lists his own tasks
curl localhost:8000/api/tasks -H "Authorization: Bearer $BOB_TOKEN"
# []  ← empty, Alice's task doesn't show up

Exactly as designed: Bob can't view, let alone modify or delete, Alice's task — even though he knows its exact ID.

Rate Limiting — Preventing Brute Force on the Login Endpoint

Now for the second part: preventing someone from trying thousands of password combinations against the login endpoint. We use Redis (already familiar from the Scalability series) as a counter, with a fixed-window pattern:

def rate_limit_login(request: Request, cache: redis.Redis = Depends(get_redis)) -> None:
    identifier = request.client.host if request.client else "unknown"
    key = f"rate-limit:login:{identifier}"

    current_attempts = cache.incr(key)
    if current_attempts == 1:
        cache.expire(key, settings.login_rate_limit_window_seconds)

    if current_attempts > settings.login_rate_limit_max_attempts:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Too many login attempts. Try again later.",
        )

How it works: Redis's INCR bumps the counter and returns its new value atomically (safe from race conditions even with many concurrent requests). The FIRST attempt within a window is the one that sets expire — so the counter automatically resets to 0 once login_rate_limit_window_seconds seconds pass, with no separate cleanup job needed.

Attached as a dedicated dependency on the login endpoint:

@router.post("/login", response_model=Token, dependencies=[Depends(rate_limit_login)])
def login(...):
    ...

Proving it with a string of failed login attempts (default: max 5 attempts per 60 seconds):

for i in 1 2 3 4 5 6 7; do
  curl -s -o /dev/null -w "attempt $i: %{http_code}\n" \
    -X POST localhost:8000/auth/login -d "username=alice&password=wrongpassword"
done
attempt 1: 401
attempt 2: 401
attempt 3: 401
attempt 4: 401
attempt 5: 401
attempt 6: 429
attempt 7: 429

The first five attempts stay 401 (the password really is wrong), but attempt #6 onward gets rejected immediately with 429 Too Many Requests — BEFORE the password is even checked. An attacker attempting brute force is now drastically rate-limited, while a legitimate user who just fat-fingers their password occasionally isn't disrupted.

An important note: this rate limiter counts EVERY attempt from a single source (here, by IP address), successful or failed — not just failures. For a more mature production system, consider a separate rate limit just for failed attempts (so a legitimate user who logs in successfully several times in quick succession, say from multiple tabs, doesn't get caught by the limit too).

Summary

  • get_current_user extracts identity from the JWT, used as a dependency on every endpoint that needs protection.
  • Object level authorization — EVERY access to a specific resource must verify ownership; authentication alone isn't enough.
  • Use 404, not 403, for a resource that isn't the user's — this prevents leaking information about that resource's existence.
  • Redis-backed rate limiting (INCR + EXPIRE) prevents brute force on the login endpoint with minimal overhead.
  • Topics

    SecurityAuthorizationRate Limiting