Security review

Your Frontend Auth Check Is Not Authorization

Published June 25, 2026

I see this bug a lot in early products: the frontend hides a button, redirects away from a route, or checks user.role === "admin", and everyone treats that as authorization.

It is useful UI state. It is not the security control. The backend still has to decide whether this user can do this action on this resource.

The mental model

I separate auth into three checks:

  • Authentication: who is this request from?
  • Authorization: is that user allowed to do this exact thing?
  • Presentation: what should the UI show that user?

The frontend only owns presentation. It makes the app pleasant to use, avoids showing controls that will fail, and saves a round trip. But the browser is controlled by the user, and requests do not have to come from your buttons.

OWASP's Authorization Cheat Sheet says permissions should be validated on every request. OWASP ASVS is even more direct: access control rules should be enforced on a trusted service layer, especially when client-side access control exists and can be bypassed.

The bug pattern

This frontend check looks sensible:

if (!user.isAdmin) {
  router.push("/")
}

async function deleteUser(userID: string) {
  await fetch(`/api/users/${userID}`, { method: "DELETE" })
}

Look at the API shape. If DELETE /api/users/:userID trusts the frontend route guard, the route guard is the only thing standing between a normal user and an admin action.

A browser console, curl, an intercepted request, or a copied access token is enough to test that endpoint directly. The UI never gets a vote.

The backend has to ask the full question

I want the backend code to make the decision with data it controls:

app.delete("/api/users/:userID", requireSession, async (request, response) => {
  const actor = await getUserForSession({ sessionID: request.session.id })
  const targetUserID = request.params.userID

  if (!canDeleteUser({ actor, targetUserID })) {
    return response.sendStatus(403)
  }

  await deleteUser({ userID: targetUserID })
  return response.sendStatus(204)
})

Framework choice matters less than the question: canDeleteUser({ actor, targetUserID }).

That function should load roles, tenant membership, ownership, and plan data from the database or from server-issued session claims that users cannot edit.

The places people miss

The obvious page route is usually checked. The missed checks tend to be near the edges: import endpoints, export endpoints, signed download URLs, webhook handlers, admin helpers, background job triggers, and "temporary" support tools.

I also look for duplicate code paths. A product may check permissions in POST /api/projects/:id/invite, then forget the same rule in a bulk invite endpoint added later. If two endpoints perform the same business action, both need the same authorization decision. I usually search by database table, service function, and route verb because UI labels miss these copies.

How I diagnose it in a review

I usually start with the routes, not the components.

  • Find every mutating endpoint: POST, PUT, PATCH, DELETE.
  • Find every endpoint that returns private data.
  • For each one, identify the actor, action, resource, and tenant or owner boundary.
  • Check whether the backend makes the decision before doing work.
  • Check whether the decision uses server-owned data.
  • Write at least one negative test for another user's resource.

The simplest useful test is usually a horizontal access test. Create two users and a project owned by the first user. Sign in as the second user, then ask for the first user's project by ID.

The correct result is 403 or 404, depending on your product's disclosure policy. Returning the project JSON fails the test.

Check the query shape

I also look at the database query behind the endpoint. A lot of authorization bugs are one missing predicate.

This is the query shape I do not want for tenant-owned data:

const project = await db.project.findFirst({
  where: { id: projectID },
})

It proves the project exists. It does not prove the actor can read it.

const project = await db.project.findFirst({
  where: {
    id: projectID,
    organizationID: actor.organizationID,
  },
})

That version ties the lookup to the actor's organization. For owner-based products, the predicate might compare the owner ID with the actor's ID. For shared projects, it might be a membership join. The exact model changes. The review question stays the same: can the user change an ID and escape their boundary?

Keep policy code boring

I prefer boring policy functions over scattered inline checks. They are easier to test, and they make the business rule visible.

function canReadProject({
  actor,
  project,
}: {
  actor: User
  project: Project
}) {
  return actor.organizationID === project.organizationID
}

For a small product, this may be enough. If permissions get more complex, move to a more formal model later. I would rather see five boring policy functions today than a half-used permissions framework that nobody understands.

test("user cannot read another user's project", async () => {
  const owner = await createUser()
  const otherUser = await createUser()
  const project = await createProject({ ownerID: owner.id })

  const response = await request(app)
    .get(`/api/projects/${project.id}`)
    .set("Cookie", await sessionCookieFor({ userID: otherUser.id }))

  expect(response.status).toBe(403)
})

The client still has a job

I still want frontend checks. They are part of the user experience. If a normal user cannot delete users, do not show the delete button. If an expired session is obvious, redirect to sign-in.

But I treat those checks as hints. Trust the server response.

The same applies to cookies and tokens. HttpOnly, Secure, and SameSite cookies are good session hygiene. MDN recommends those attributes for session identifiers and cross-site request limits. They do not answer whether this signed-in user can delete this resource. That still belongs in application code on the backend.

Handle denial like a product state

A good authorization check also needs a boring failure path. Return 401 when the request has no valid session. Return 403 when the user has a valid session and lacks permission. Use 404 when revealing that the resource exists would leak information.

The frontend should handle those states without pretending it made the decision. A disabled button can explain why an action is unavailable. A failed API call still needs a clear error state, because stale tabs, changed permissions, expired sessions, and copied requests are normal production behavior.

I also want failed authorization checks logged once on the backend with useful metadata: actor ID, action, resource type, resource ID if safe, and request ID. Do not log session tokens, full cookies, or private payloads. The log should help you spot repeated probes without creating a new data leak.

One small habit helps during development: keep a second low-privilege test account handy. When you add a page for admins, project owners, or paid plans, try the API with that account before calling the feature done. It takes a minute and catches mistakes that visual testing misses. I keep this test account outside my normal happy path.

What I look for

When I review this part of a codebase, these are the checks I care about first:

  • Call the API without visiting the frontend route.
  • Change an ID and try to read or update another user's record.
  • Change a tenant ID, organization ID, role, or plan value in the request.
  • Do background jobs, webhooks, and admin endpoints use the same permission model?
  • Are failures denied by default, or does missing policy accidentally allow access?
  • Are failed authorization checks logged with useful metadata and no secrets?

The fix is usually not a giant permissions system. For many products, a few small policy functions close most of the gap. The win is putting those checks where the request is handled, using data the user cannot edit, and testing the cases the UI tries to hide.

Sources I checked

For the way I review these issues in client projects, see the Codevetta review process.