Troubleshooting guide

API endpoints answer without login and secrets ship in the bundle

An endpoint that should require a signed-in owner answers anyone. A tool server hands out environment secrets, a review page skips login, visitors on a shared demo account can delete its content, or a query parameter decides where the server sends a request, token included. The author only tests from their own account, so nobody notices until a stranger tries it. This pattern appeared in 5 verified builder reports in our data, in both hand-built and AI-generated apps.

5 verified cases9 min read Updated 27 Sep 2026How we collect cases

What you'll see

  • A request with no cookie and no Authorization header gets a 200 and real data from an API route, a Server Action or an internal tool server.
  • Changing an id in the URL or request body lets one account read, edit or delete another account's record.
  • Visitors signed in to a shared demo account have edited or deleted the seeded content on the live site.
  • A page lets someone write a review or post without signing in. The server then rejects the save and the text is lost.
  • Searching the production JavaScript for STRIPE_SECRET_KEY, SERVICE_ROLE or sk_live_ finds a match.
  • An endpoint takes a URL, host or callback from a query parameter and fetches it from the server with your service token attached.

Why it happens

Cause 1

Authorization is written by hand, route by route

Each handler carries its own check, so any handler that was generated or copied without one is open. In Next.js, a check at page level does not protect the Server Actions defined on that page. Every exported action can be called with a direct POST, whether or not your UI uses it. The same is true of any route handler or serverless function: leaving it unlinked does not protect it.

Cause 2

Only ever tested by the author, signed in

A route that checks the session but not ownership passes every test run from the author's own account. OWASP's point is that access to one object of a type does not mean access to every object of that type. The bug only shows up with a second account or with no account at all.

Cause 3

Internal tool servers treated as private

An MCP or admin tool server deployed on a public URL is public. The MCP specification makes authorization optional, so a server that works without a token also works for anyone else. If a tool returns process.env or an admin token for debugging, every caller can read it.

Cause 4

Server config imported into shared modules

At build time, Next.js inlines NEXT_PUBLIC_ variables and Vite inlines VITE_ variables into the client bundle. If a secret is given the public prefix to silence a client error, its value ships to the browser. If a client component imports a shared env or config module, the list of secret names and their expected formats ships too.

Cause 5

Outbound URLs built from request input

If a handler fetches a URL or host taken from the query string, the caller chooses where your server sends its request, along with the Authorization header you add. This is server-side request forgery (SSRF). fetch follows redirects by default, so even a validated URL can redirect to another origin.

How to fix it

  1. Deny by default in one server-side helper

    Put session verification in a server-only module and call it at the top of every route handler, Server Action and tool handler. Here auth() stands for your auth library's session call. In code review, treat any handler that does not call the helper as a bug.

    import 'server-only'
    
    export async function requireUser() {
      const session = await auth()
      if (!session?.user) throw new Error('Unauthorized')
      return session.user
    }
  2. Check existence and ownership on the server for every object

    Load the record and return 404 if it is missing. Before any read or write, compare its owner with the session user. Never trust an owner id, role or isAdmin flag sent by the client.

    const user = await requireUser()
    const review = await db.review.findUnique({ where: { id } })
    if (!review) return new Response(null, { status: 404 })
    if (review.authorId !== user.id) return new Response(null, { status: 403 })
  3. Put authentication in front of tool servers

    Keep an internal MCP or admin server off the public internet, or require a bearer token on every request and answer a missing or invalid token with 401. The MCP specification says HTTP servers that support authorization must check that the token was issued for them and must never pass it on to upstream APIs. Remove any tool that returns environment variables.

  4. Keep secrets out of client code and scan the build

    Never give a secret a NEXT_PUBLIC_ or VITE_ prefix. Add import 'server-only' to every module that reads process.env, so importing it from the client fails the build. Before each release, search the built client output for secret names and key prefixes.

    npm run build
    grep -rlE 'sk_live_|sb_secret_|SERVICE_ROLE|SECRET_KEY' .next/static dist 2>/dev/null
  5. Rotate anything that shipped

    Removing a key from the code does not remove it from builds that are already deployed or cached. Issue a new key at the provider, deploy it as a server-only variable, then revoke the old one.

  6. Build outbound URLs from an allowlist

    Accept a short name, map it on the server to a fixed origin, and reject anything else. Do not accept full URLs from the request. Set redirect: 'error' so a redirect cannot send the request to another origin.

    const ORIGINS = { images: 'https://images.example.com' } as const
    const origin = ORIGINS[target as keyof typeof ORIGINS]
    if (!origin) return new Response(null, { status: 400 })
    const res = await fetch(new URL('/v1/render', origin), {
      redirect: 'error',
      headers: { Authorization: `Bearer ${process.env.RENDER_TOKEN}` },
    })
  7. Make demo accounts read-only, or reset them

    Enforce read-only access for the demo user on the server, not by hiding buttons. If visitors must be able to write, restore the seed data on a schedule. Restore it now as well, since the live data may already have been changed.

Check it's fixed

  • Call every route, Server Action and tool endpoint with curl and no cookie or Authorization header. Every one that is not meant to be public returns 401.
  • Sign in as a second account and try to read, edit and delete the first account's records by id. Each attempt returns 403 or 404 and nothing changes.
  • Search the production client bundle, and the Sources panel of browser DevTools on the live site, for secret names and key prefixes. Nothing matches.
  • Call the proxying endpoint with a target that is not on the allowlist. It returns 400, and your server logs show no outbound request.

Questions

Is an API route safe if nothing in my app links to it?

No. Anyone can call a deployed route handler or function URL directly, and Next.js documents that exported Server Actions can be called with a direct POST. Check authentication and authorization inside each one.


Can I put an API key in NEXT_PUBLIC_ or VITE_ if I minify the code?

No. Both frameworks inline those values into the JavaScript sent to the browser at build time, and minifying does not hide a string. Call the provider from a server route and keep the key in a variable without the public prefix.


Does a Supabase secret or service_role key respect my RLS policies?

No. Supabase's docs say a secret key bypasses every Row Level Security policy and must never go in a browser, a shipped app or source control. The newer sb_secret_ keys are refused with 401 when Supabase detects a browser User-Agent.


Does my MCP server need authentication?

The specification makes authorization optional, but an HTTP server without it answers anyone who finds the URL. For a local STDIO server, the specification says to take credentials from the environment instead.


Should another user's record return 403 or 404?

Either one stops the leak. A 404 for records the caller does not own also avoids confirming that the id exists.


Sources

← Back to the full report