---
title: "Auth checked only in the UI"
slug: auth-checked-only-in-the-ui
severity: critical
category: Access control
summary: "Hiding a button or a page does not stop a request. The server, the database policy, or both have to reject it."
platforms:
  - Next.js
  - Supabase
  - Firebase
  - Vercel
updated: "2026-10-13"
related:
  - server-actions-without-auth
  - idor
  - supabase-tables-without-rls
  - firebase-rules-left-open
---

A vibe-coded app often "adds auth" by wrapping a React page in a check that redirects to login, and by not rendering the admin link. The API route, Server Action, or database is still open. Anyone who can send an HTTP request can skip the component.

Next.js middleware (called proxy in current Next.js docs) can reject requests, and it helps. It is not the whole control. A matcher that skips `/api`, a new route that does not match, or a Server Action invoked directly can walk past a gate that only lived in the layout.

If the browser talks to Supabase with the publishable key, row level security is the check that still runs when the UI is bypassed. If the server uses the secret key, that bypasses RLS, so the server code itself has to identify the user and decide.

## How to check in 5 minutes

1. Sign out. Load a private URL directly, such as `/app`, `/admin`, or `/api/projects`. A redirect in the browser is not enough if the API still returns data.
2. Search Route Handlers and Server Actions for database writes. The first lines should resolve the session and return 401 when it is missing. A comment that says "the page already checks" is the bug.
3. Search for `redirect("/login")` that is the only check. Open the fetch or server function it was meant to protect and see if that function checks too.
4. With Supabase, confirm RLS is on. With Firebase, read the rules. The client SDK will do whatever the rules allow, no matter what the React tree hides.

## How to fix

### Next.js

In every Route Handler and Server Action that reads or writes user data, load the session from cookies or from your auth library on the server, and stop when there is no user. Do the same for admin actions with an explicit role check stored somewhere the user cannot edit.

A layout guard is fine as a convenience so people do not see a flash of the wrong page. Keep the check next to the data.

```ts
export async function POST(request: Request) {
  const user = await getCurrentUser();
  if (!user) {
    return new Response("Unauthorized", { status: 401 });
  }
  // use user.id from the session, not from the JSON body
}
```

Review the middleware or proxy matcher. If it is the only check, a path it does not list is unprotected.

### Supabase

Prefer the user's JWT and RLS for ordinary reads and writes, so a forgotten server check still hits the database policy. Use the secret key only for work that has already authorized the caller.

### Firebase

Security rules are the enforcement point. A React route guard is not. See [Firebase rules left open](/issues/firebase-rules-left-open).

### Vercel

Deployment protection (a Vercel login in front of a preview) protects the preview URL. It does not protect your production API from the public internet. Do not treat a locked preview as production auth.

## Agent prompt

```text
You are adding real authorization. Do not add features.

The UI is not an access check. Find every Route Handler, Server Action, and API helper that reads or writes data.

For each one:
1. Resolve the signed-in user on the server.
2. If there is no user, return 401 before touching the database.
3. Use the id from the session. Ignore userId, role, or isAdmin fields in the request body.
4. Leave any existing UI redirects in place. They are not the fix.

If this app uses Supabase with the secret key, list those call sites and switch user-facing queries to the publishable key plus the user session, or filter them by the session user yourself.

Do not invent a role system. If you cannot tell how admin is stored, stop and ask.

Summarize each route you changed and the check you added.
```

## FAQ

### If middleware redirects logged-out users, is that enough?

It is one layer. It fails open when a route is missing from the matcher, and it is easy for a later change to bypass. Check again inside the function that touches data.

### We only have a frontend. There is no API.

If the frontend talks to Supabase or Firebase, that service is the API. The rules or RLS policies are the auth check. Hiding a screen in React does not change them.

## Sources

- [Supabase API keys: what the key identifies versus who the user is](https://supabase.com/docs/guides/api/api-keys)
- [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
