Guides · pre-launch guide · 2026-09-07
API routes and server actions with no auth check
The most common serious problem in AI-built web apps is a server endpoint that does real work (reads private rows, writes data, spends money on an LLM or Stripe call) without first confirming who is calling and whether they own the thing they are touching. This guide covers why it keeps appearing, how to find it in your own repo, real instances from public starters, and how to fix it before launch.
Why this keeps happening in AI-generated code
Coding assistants write the handler you asked for. If the prompt was "add an endpoint that deletes a post", the output is a function that deletes a post. The auth check is a separate concern that nobody asked for, so it is often left out or added as a soft session?.user.id that silently degrades to nothing.
A second cause is that the tools treat Next.js Server Actions as ordinary functions. Any exported async function in a file starting with "use server" becomes an HTTP endpoint. Helpers that were meant to be called only from other server code end up publicly invokable.
A third cause is copied scaffolding. Many starters use a service-role or admin database client in API routes because it is convenient during development. That client bypasses row-level security, so the only thing standing between the internet and every user's data is a session check that may not exist. Middleware matchers that exclude /api/* compound this: the author believes middleware protects everything, and the API routes are the exception.
- The happy path was specified; the unauthorized path was not.
"use server"exports are endpoints, but they look like plain functions.- Service-role / admin clients skip RLS, so a missing session check is a full bypass.
- Middleware matchers frequently exclude
/api, and the API routes never get their own check. - Optional safeguards (rate limiting only
if (ratelimit)) become no safeguards in production when the env vars are missing.
How to recognise it in your own repo
Do this as a mechanical pass, not by reading and hoping. List every server entry point, then for each one confirm two things: it establishes a session before doing anything, and every database read or write is scoped to that session's user. If the handler uses an admin client, the scoping must be explicit in the query.
Pay special attention to optional chaining on the session. session?.user.id passed into a Prisma where is a known trap: Prisma treats undefined as "filter not set", so { id: postId, authorId: undefined } matches any post. The same pattern with other ORMs can behave the same way.
- Grep
route.tsandroute.jsunderapp/andpages/api/. Every exportedGET,POST,PATCH,DELETEis an endpoint. - Grep
"use server"and'use server'. Every exported async function in those files is an endpoint, whether or not a client imports it. - Grep
SUPABASE_SERVICE_ROLE_KEY,createClient(with the service key,supabaseAdmin, or any admin/service client. Each usage needs a session check and a.eq("user_id", ...)(or equivalent) on the query. - Grep
session?.anduser?.idinwhere:clauses. Optional chaining feeding a query filter is a bypass waiting to happen. - Grep
getServerSession,auth(),getServerProfile,getUserand compare the list of files that call them against the list of route and action files. The difference is your work list. - Open
middleware.tsand read thematcher. If it excludes/apior/api/(.*), middleware protects none of your API routes. - Grep for
if (ratelimit),if (redis)or similar guards around paid calls. If the limiter is optional, production probably runs without it. - Look for
query(vsqueryWithUser((Convex) or the equivalent authenticated/unauthenticated builder pair in whatever backend you use.
Real examples: API routes
These are from public starters that many AI-built apps are cloned or derived from. If your app started from one of them, check these exact files.
shadcn-ui/taxonomy, app/api/posts/[postId]/route.ts:88. verifyCurrentUserHasAccessToPost runs db.post.count({ where: { id: postId, authorId: session?.user.id } }) without first checking a session exists. With no cookie, authorId is undefined, Prisma drops the filter, the count is 1 for any real post, and both DELETE (line 23) and PATCH (line 53) proceed. The middleware matcher at middleware.ts:45 does not cover /api/*. Result: anyone with a post ID can delete or overwrite it with one curl request.
mckaywrigley/chatbot-ui, app/api/chat/custom/route.ts:20. The route never calls getServerProfile(). It creates a service-role Supabase client (lines 20-23), loads a model by customModelId from the request body (line 28), and uses that model's stored api_key and base_url (lines 35-38) to run completions. Any unauthenticated caller who knows or guesses a models.id can run unlimited completions billed to another user's key, and the server will make requests to whatever base_url that row contains. The same starter has app/api/username/get/route.ts:18 and app/api/username/available/route.ts doing unauthenticated service-role lookups that allow bulk username enumeration.
nextjs/saas-starter, app/api/stripe/checkout/route.ts:91. The route is unauthenticated (middleware excludes /api). It reads client_reference_id from a Stripe checkout session, loads that user, writes subscription data, and calls setSession(user[0]), issuing a session cookie for that user to whoever presented the session_id. It never checks that the caller is that user or that session.status === 'complete'. Checkout session IDs appear in URLs, history, referrers and logs, so a leaked cs_... value is an account login.
Real examples: Server Actions
Server Actions are POST endpoints. The action ID ships to the browser when a client component imports the function, and Next.js treats every export of a "use server" module as public regardless.
vercel/ai-chatbot, artifacts/actions.ts:5. getSuggestions({ documentId }) is exported from a "use server" file and imported by artifacts/text/client.tsx (line 122). It calls getSuggestionsByDocumentId with no auth() and no userId comparison. The sibling route app/(chat)/api/suggestions/route.ts does check suggestion.userId !== session.user.id; the action skips it. Any user, including a guest, can pass any document UUID and receive originalText and suggestedText rows, which are verbatim sentences from someone else's document.
vercel/ai-chatbot, app/(chat)/actions.ts:23. generateTitleFromUserMessage is exported from the same kind of file. It calls generateText with a client-controlled message, no auth(), no rate limit, no length cap. It was only meant to be called from app/(chat)/api/chat/route.ts line 132, but because it lives in a "use server" module it is a public endpoint that bypasses the per-user quota and bot protection on POST /api/chat.
vercel/nextjs-subscription-payments, utils/stripe/server.ts:76. checkoutWithStripe(price: Price, redirectPath) (line 21) accepts the whole price row from the browser. price.trial_period_days becomes subscription_data.trial_end (line 76) and price.id / price.type pick the line item and mode (lines 59, 71-84). Nothing is re-read from the database. This one has a logged-in user, but it still trusts client input for authorization-relevant data: a caller can send { id: 'price_xxx', type: 'recurring', trial_period_days: 3650 } and grant themselves a ten-year trial on any active price.
How to fix it
The pattern is the same everywhere. First, establish identity and fail closed: get the session at the top of the handler, return 401 if there is none, and never let a possibly-undefined user ID flow further. Second, scope the operation to that user in the query itself rather than in a separate check: deleteMany({ where: { id, authorId: session.user.id } }) and return 404 when count === 0, or .eq("user_id", profile.user_id) on a Supabase query. Third, prefer a user-scoped client (anon key plus request cookies, so RLS applies) over a service-role client in request handlers.
For Server Actions, keep only functions that a client component genuinely calls in "use server" files. Move internal helpers to a plain module with import "server-only" at the top and import them from your route handlers. Where a client passes an object (a price, a model, a document), accept only an ID and re-read the row server-side.
For paid endpoints (LLM, image generation, uploads) auth alone is not enough. Make rate limiting mandatory rather than conditional on env vars being present, key it on user ID or IP, and add a global daily cap as a circuit breaker so a leak is bounded. The prompt below is written for Cursor or Claude Code; paste it and review the diff file by file.
- Session first, 401 on missing, no optional chaining into queries.
- Ownership enforced inside the read/write query, 404 on zero rows.
- User-scoped DB client in request handlers; service-role only in webhooks and jobs.
- Only real client-callable actions in
"use server"files; internal helpers go behindimport "server-only". - Accept IDs from the client, re-read the row on the server.
- Rate limiting is required, not optional, on anything that costs money.
Audit every server entry point in this repo for missing authentication and authorization, then fix them. Do the following:
1. Enumerate all endpoints: every exported GET/POST/PATCH/PUT/DELETE in route.ts/route.js files under app/ and pages/api/, and every exported async function in any file that starts with "use server". List them in a table with file path, function, and whether it currently (a) verifies a session before doing any work, (b) scopes every database read/write to that session's user, (c) uses a service-role/admin database client.
2. Read middleware.ts and report exactly which paths the matcher excludes. Do not assume middleware protects /api routes.
3. For every endpoint that lacks a session check, add one at the very top using the project's existing helper (getServerSession, auth(), getServerProfile, getUser, or equivalent) and return a 401 response (or throw "Unauthorized" for a server action) if there is no user id. Never pass session?.user.id or any possibly-undefined value into a database where clause; Prisma and similar ORMs drop undefined filters.
4. For every read or write on user-owned data, enforce ownership inside the query itself (e.g. Prisma deleteMany/updateMany with { id, authorId: session.user.id } and return 404 when count === 0; Supabase .eq("user_id", user.id)). Remove separate pre-check helpers that count rows and then mutate without an owner filter.
5. Where a request handler uses a service-role or admin client, replace it with a user-scoped client built from the request cookies so RLS applies, unless the handler is a webhook or background job. If the admin client must remain, add an explicit user_id filter and explain why.
6. In every "use server" file, keep only functions that are actually imported by a client component. Move internal helpers to a plain module that begins with import "server-only" and update imports in route handlers. Flag any server action that accepts a full object from the client where an id would do, and change it to accept the id and re-read the row server-side.
7. For endpoints that call paid services (LLM providers, image generation, Stripe, presigned uploads), ensure rate limiting is mandatory: if the limiter is not configured, return 503 rather than proceeding. Key limits on user id where a session exists, otherwise on x-forwarded-for, and add a global daily cap.
8. Do not change unrelated behaviour. Produce one commit per file or logical group with a short message stating which check was missing. At the end, print the table from step 1 again with the new status of each endpoint.Pre-launch checklist
Work through this list once against the real production branch, not a local copy with extra env vars set.
- Every
route.tshandler and every"use server"export appears in a list, and each one has been confirmed to check a session before any DB or external call. - No
session?.user.idor other optional-chained value is passed into a query filter anywhere. - Every mutation on user-owned data is scoped by owner in the query and returns 404 on zero rows.
- No request handler uses a service-role or admin client without an explicit owner filter; webhooks and jobs are the only exceptions.
middleware.tsmatcher has been read, and every excluded path prefix has been checked by hand.- Internal helpers that make LLM or payment calls are not exported from
"use server"files. - Server actions and routes accept IDs, not full objects, for anything that affects billing or access.
- Rate limiting on paid endpoints fails closed when Redis or the limiter env vars are missing, and a global daily cap exists.
- No endpoint issues a session cookie based on an identifier the client supplied (checkout session IDs, user IDs in the body).
- Redirect targets from query parameters are validated to be same-origin relative paths.
- Developer scaffolding that reports config state (for example an unauthenticated env-var check query) has been removed or gated to non-production.
- Each fix was tested with a plain curl and no cookie, and again with a second test account's cookie against the first account's data.
Check your own repo for this.
Free quick scan reads your highest-risk files in under a minute. The $19 deep audit reads the whole codebase and writes a fix prompt per finding.
Scan my repo →Written from VibeAudit audit findings. Public open-source starters are named with file and line; other apps are anonymized. Not a substitute for a professional security assessment.