Home/Learn/Full-Stack Integration/Authorisation — Who Enforces What

Authorisation — Who Enforces What

Advanced
Auth in the Browser

The frontend decides what to show; the backend decides what is allowed. Every rule needs to exist on both sides, and only one of them is security.

Overview

Authentication asks who you are; authorisation asks what you may do, and it is where real breaches happen. The rule is short — the server enforces, the client only hides — but it is violated constantly, because hiding the admin link feels like it did something. It did not: the bundle is public, the endpoint is reachable with curl, and the id in the URL can be changed. The most common vulnerability in web applications is still an endpoint that checks you are logged in and forgets to check the record belongs to you.

Two Places, Two Purposes

The same rule expressed twice, for entirely different reasons.

Hide in the UI, enforce and strip on the server
// Frontend — so users are not shown doors they cannot open
{user.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
{isPro ? <Solution /> : <UpgradePrompt />}

// Backend — so the rule is actually true
@router.delete("/problems/{slug}")
async def delete_problem(slug: str, user = Depends(require_role("admin"))):
    ...

// Skipping the frontend check: a poor experience, users hitting 403s.
// Skipping the backend check: a vulnerability.

// The two failures to internalise:
//   1. Hiding a link hides nothing. curl reaches the endpoint.
//   2. Gating in the client means the DATA was still sent. If the
//      API returns the solution and the UI hides it behind a Pro
//      gate, the solution is in the network tab.
//      Strip it server-side:
if not user.is_pro:
    problem.solution = None
    problem.hints = []

// Same for prices, other users' emails, internal notes — anything
// the response contains is public to whoever requested it.

Object-Level Checks

The most common real vulnerability: authenticated, but not yours.

Ownership belongs in the query
# Broken — any logged-in user can read any submission by id
@router.get("/submissions/{id}")
async def get(id: int, user = Depends(current_user)):
    return await db.get_submission(id)       # whose?

# Fixed — ownership is part of the query, not a separate check
@router.get("/submissions/{id}")
async def get(id: int, user = Depends(current_user)):
    row = await db.get_submission(id, user_id=user.id)
    if row is None:
        raise HTTPException(404)             # 404, not 403 — do not
    return row                               # confirm it exists

# This class of bug (IDOR / broken object level authorisation) sits at
# the top of the OWASP API list year after year, because the endpoint
# looks correct: it does have an auth dependency.

# Multi-tenant systems need the tenant in every query, enforced
# centrally rather than remembered per endpoint:
stmt = select(Booking).where(Booking.clinic_id == ctx.clinic_id)
# One forgotten filter leaks one clinic's data to another.

# Sequential integer ids make enumeration trivial. UUIDs are not
# authorisation, but they do stop casual scanning.

Keeping the Two in Sync

One source of truth for permissions, and a stale-role trap.

Server-owned permissions, sent to the client
// Define capabilities once, server-side, and send them with the user
GET /auth/me
{ "id": 42, "role": "tpo",
  "permissions": ["college:read", "students:invite", "reports:export"] }

// The frontend renders from that list rather than re-implementing
// the rules — so a permission change does not need a UI release
{can('students:invite') && <InviteButton />}

// A shared permission map in a monorepo package works too, as long
// as the SERVER is the one that evaluates it.

// The stale-permission trap: a role stored in a JWT is fixed until
// the token expires. Demote an admin and they stay an admin for the
// life of the token. Either keep permissions out of the token and
// look them up, or keep the token short and version it.

// Fail closed. A missing rule must deny, never allow:
def require(permission):
    def dep(user = Depends(current_user)):
        if permission not in user.permissions:   # unknown -> denied
            raise HTTPException(403)
    return dep

// And log authorisation failures. A spike of 403s from one account
// is either a broken client or someone probing.

Key Points to Remember

  • 1The frontend hides what a user cannot use; only the backend enforces it, because the bundle and the API are public
  • 2Gating in the UI still ships the data — strip solutions, prices and other users' fields server-side
  • 3Checking authentication without checking ownership is the most common API vulnerability there is
  • 4Put ownership and tenant filters into the query itself, and return 404 rather than 403 to avoid confirming existence
  • 5Send server-computed permissions to the client so both sides agree, and remember roles inside a JWT go stale until it expires

Interview Questions

Sign in to ask Aria
1

Why is hiding an admin button not a security measure?

Easy
2

What is broken object level authorisation, and how do you prevent it?

Hard
3

A user is demoted from admin. Why might they still have admin access?

Hard

Ask Aria about Authorisation — Who Enforces What

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…