There is one rule underneath everything in this post, and if you only take one thing away, take this:

The API enforces permissions. The UI only avoids showing people doors they cannot open.

Every mistake I have seen in this area comes from blurring that line. A hidden button is not a permission check. It is a courtesy.

The shape of it

A working setup has four pieces:

  1. Authentication. Who is this? A token proves it.
  2. Authorisation. What may they do? Roles and permissions decide.
  3. Enforcement on the API. Every protected route checks, every time.
  4. Reflection in the UI. Routes and controls adapt to what the token says, purely so the experience is not confusing.

Enforcement on the API

In FastAPI, a dependency is the natural place for this. It runs before the handler, and the handler stays about its own job.

from fastapi import Depends, HTTPException, status

def require_role(*allowed: str):
    def checker(user: User = Depends(current_user)) -> User:
        if user.role not in allowed:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Not permitted for this role",
            )
        return user
    return checker

@router.delete("/datasets/{dataset_id}")
def delete_dataset(
    dataset_id: int,
    user: User = Depends(require_role("admin", "owner")),
):
    ...

Two details that matter:

401 and 403 are different. 401 means “I do not know who you are”, and the client should send credentials. 403 means “I know exactly who you are and the answer is no”, and retrying with the same token is pointless. Conflating them produces clients that loop on a refresh they do not need.

Check the object, not only the role. require_role("owner") confirms the user is an owner. It does not confirm they own this dataset. Role checks and ownership checks are separate questions, and the second one is where the real vulnerabilities live.

Token handling

Short-lived access token, longer-lived refresh token. The access token carries the user id, the role, and an expiry, and it is signed, not encrypted, so assume anyone holding it can read it. Nothing secret goes in the payload.

On storage, the honest position is that every option is a trade-off:

  • localStorage is readable by any script that runs on your page. If you have an XSS hole, the token is gone. It is simple and it survives a reload.
  • An httpOnly cookie is not readable by script, which removes that risk and introduces CSRF, which you then handle with SameSite and a CSRF token.

An httpOnly cookie with SameSite=Lax or Strict plus CSRF protection is the stronger default. What matters more than the choice is knowing which attack you have accepted, and having said so out loud.

Keep the expiry short. Fifteen minutes on the access token means a leaked token has a small window. The refresh flow makes that invisible to the user.

Reflection in the UI

Now the front end. A protected route in React is a wrapper, not a security feature:

function RequireRole({ allowed, children }) {
  const { user, loading } = useAuth();
  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/sign-in" replace />;
  if (!allowed.includes(user.role)) return <Navigate to="/no-access" replace />;
  return children;
}

Note replace. Without it, the browser back button walks the user straight back into the route they were just bounced out of, and they get a confusing flash before the redirect fires again.

Hide controls the user cannot use, but assume they will find the endpoint anyway. Someone reading your bundle can see every route you defined.

CORS, named explicitly

If the API and the front end are on different origins, set the allowed origins by name:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

Never reflect the incoming Origin header back. allow_origins=["*"] with allow_credentials=True is rejected by browsers anyway, and the workarounds people reach for when they hit that error are usually worse than the original problem.

The failure modes to test for

Write these as tests, not as intentions:

  • A request with no token gets 401.
  • A request with an expired token gets 401.
  • A valid token with the wrong role gets 403.
  • A valid token with the right role but the wrong object owner gets 403.
  • A token with a tampered payload is rejected by signature verification.

That last one is worth doing once by hand, just to watch it fail. It is a good reminder of what the signature is actually for.

The short version

  • The API is the enforcement point. The UI is presentation.
  • 401 is “who are you”, 403 is “no”.
  • Role checks and ownership checks are two different checks.
  • Short access tokens, a refresh flow, and a storage choice you can defend.
  • Name your CORS origins.

Related

Next step

Working on something this touches?

If this post lines up with a problem you have, I would rather hear about the problem than the post. Tell me what you are building.

  • Replies: Within two working days
  • Based in: the United States
  • Open to: full time, contract, remote or hybrid

Pages

Projects

Writing

Actions