If a stranger can open /dashboard by pasting the URL, do you still want to call that login?
I ask that question in the first hour of almost every AI Next.js rescue. The founder is proud of the sign-in screen. The generated app even “protects” the app shell. Then I disable JavaScript, or I call the chat API with curl, and the product keeps talking. That is not a lock. That is furniture.

Three myths that keep leaking AI apps
The first myth is the Bolt and v0 special. The generator wraps the dashboard in a client component. On mount, it reads a token from localStorage and pushes the visitor to /login. The founder sees the bounce and calls it auth. A crawler, a curl client, or a user who simply waits for the HTML does not bounce. The page, and often the data route, still loads.
I watched this fail in Ahmedabad on a Monday. A seed-stage support copilot had a beautiful gate. We requested /api/threads with no cookie. The JSON came back with other people’s tickets. Nobody had been hacked in the movie sense. The lock had never been installed. The redirect had only been drawn.
The second myth is that middleware is a complete auth system. Middleware is a coarse filter. It is the right place to reject anonymous HTML navigations. It is the wrong place to be the only check on a server action that spends money or reads a mailbox. According to the Next.js authentication guide, you still verify the session where the data lives.
The third myth is that a demo can keep tokens in localStorage “for now.” There is no later. XSS in an AI UI is not exotic. Prompt-driven markdown, uploaded files, and copied chat history are all ways to run script. If the token is readable by JavaScript, the attacker becomes the user. Data from the MDN cookie documentation is explicit: HttpOnly exists so scripts cannot touch the credential.
Why do Bolt and v0 client-side redirects fail as auth?
Bolt and v0 client-side redirects fail as auth because they run after the server has already sent the page, and they never protect the API. A browser with JavaScript disabled, a crafted fetch, or a mobile client can still hit the route. Real auth is a server decision that refuses both HTML and JSON when the session cookie is missing or invalid.
Generated apps do this because it is easy to demo. A client component can read a key, call router.push('/login'), and look finished in a recording. Production does not watch the recording. Production answers HTTP.
I do not blame the generators. I blame the moment we treat a preview as a threat model. In 2026 the preview URL is already a production surface the minute you paste it into a Slack channel.
The principles that actually hold
Principle one: the browser is hostile. Treat every client bundle as public source. Secrets, role checks, and “is this user allowed to see thread 8841” belong on the server. According to the OWASP Web Security Testing Guide, access control is verified by requesting the resource, not by admiring the UI.
Principle two: the session is a cookie the script cannot read. Set it from the server. Mark it httpOnly, secure, and a strict sameSite. Rotate it on login. Invalidate it on logout. If you need a library rather than a pile of helpers, start with something purpose-built such as Lucia, then still read the session in your own routes.
Principle three: every expensive action checks the session again. Middleware can keep a stranger out of /app. The route that calls the model must still load the user, load the workspace, and refuse if the ids do not match. IDOR is how AI apps leak prompts. A missing where user_id = session.user_id is all it takes.
Principle four: authentication is not a feature you “add after the prompt is good.” It is the floor. IndiaNIC’s 14-day production rescue SLA exists because this floor is usually missing, and because you can put it in without a six-month platform rewrite. We harden the gate, the cookie, and the data checks first. The prettier chat can wait.
| If the API answers without a cookie, the login screen is theatre. |
The sequence I actually ship
This is the order from the Ahmedabad copilot, and from the ones that followed it. It fits a 14-day rescue because none of it requires a new product idea.
Day one, I kill the client gate as the source of truth. I leave the nice spinner if I must. I add middleware that reads the session cookie and redirects unauthenticated HTML. The matcher includes the app shell and the API prefix. I confirm static files still load.
export async function middleware(request) { const session = request.cookies.get('session'); if (!session) { return NextResponse.redirect(new URL('/login', request.url)); } return NextResponse.next(); }
That snippet is the start, not the finish. It does not parse the session. It does not check roles. It only proves the request had a cookie. The next function must verify the signature or the database row.
Day two, I move the session into an httpOnly cookie. The login route sets it. The logout route clears it. The client stops storing tokens. Data from Set-Cookie on MDN is enough to get the flags right. I do not invent a custom storage “layer” in React state.
cookies().set('session', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' })
Day three, I put the same check on every server action and route that reads a thread, a document, or a customer. I grep for userId in the body. I delete those parameters. The id comes from the session or it does not come.
const session = await getSession(); if (!session) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) }
Days four to ten, I add tests that would have caught the Ahmedabad leak. A request with no cookie must 401. A request with user A’s cookie must not read user B’s thread. Playwright can drive the login form. curl can drive the API. I want both.
Days eleven to fourteen, I rotate every leaked token, invalidate old sessions, and write down the threat model in the repo. IndiaNIC’s 14-day production rescue SLA is this sequence, not a slogan. We do not spend the two weeks choosing a more fashionable auth vendor unless the current one cannot set a cookie.
Test with JavaScript off, then with curl. If the dashboard HTML is sensitive, middleware must stop it. If the JSON is sensitive, the route must stop it. A green UI test that never fires /api is how the Ahmedabad leak survived a demo day. |
Is Next.js middleware enough to protect an AI product?
Next.js middleware is not enough to protect an AI product because it only filters the request at the edge of the app. Model calls, server actions, and database reads still need their own session check and a query scoped to that user. Use middleware to bounce strangers. Use the route to prove this stranger is not your customer’s data.
I still write middleware first. It removes the obvious hole. Then I walk the paid path: login, open a thread, send a prompt, download a file. At each step I ask who the server believes I am. If the answer is “whoever the client said,” the work is not done.
The Next.js docs will not ship this for you. Neither will a generator. The expert view is dull on purpose. Auth is cookies, checks, and tests. Everything else is costume.
If you want the longer rescue notes, including the ones we publish after a hardening sprint, they live on the IndiaNIC blog. Read those after you have added the cookie. Reading is not a control.
What I would do on your repo this afternoon
Open the generated layout. Find the useEffect that redirects. Leave it if you want a nicer bounce. Do not trust it. Add middleware.ts. Set an httpOnly session cookie on login. Point every data route at getSession(). Then call your own API without a cookie and confirm it fails. That afternoon is worth more than a new auth library.
Founders ask me which provider is “most secure.” I ask them whether user B can fetch user A’s prompts. Until that answer is no, the provider conversation is a distraction. Harden the gate you already have. Then, if you must, swap the vendor.
Frequently asked questions
Can I keep localStorage if I also use middleware?
You should not keep the session in localStorage even if middleware exists. Middleware can still be bypassed on a misconfigured matcher, and any XSS can steal a readable token. Put the session in an httpOnly cookie and let the server read it. The client can know that a user is signed in without holding the keys.
Does a 14-day rescue mean we cannot ship features?
A 14-day production rescue means auth, cookies, and data checks jump the queue. Small product changes can continue. New AI surfaces should not. Every new prompt box is a new place to leak context. Freeze the risky surface, harden the gate, then resume features on a server that already knows who is asking.
Where should I check roles, in middleware or in the route?
Check a coarse login in middleware and check roles in the route or server action that performs the work. Middleware is a poor place for fine permissions because it lacks full application context. Use it to refuse strangers. Use the route to refuse a viewer who is trying to act like an owner.
