# Better Auth on the edge: why your session check fails in middleware

> Edge runtimes have no TCP sockets and no Node crypto, so a session lookup that works in a page throws in the proxy. Read the cookie there and verify in the render.

You move a session check into the proxy (Next.js 16's renamed middleware) so
signed-out visitors never see a flash of the dashboard shell. Locally it looks
fine. Deployed, every request to a protected route dies with something like:

```
Error: The edge runtime does not support Node.js 'net' module.
```

or

```
TypeError: Cannot read properties of undefined (reading 'connect')
```

or (the worst version, because it looks like a logic bug) every visitor is
treated as signed out, including the ones holding a valid cookie.

## What is actually happening

The proxy runs in the edge runtime. That runtime is not Node. It has `fetch`,
Web Crypto and the standard web APIs, and it has no `net`, no `tls`, no
`node:crypto`, no filesystem. It exists to be tiny and to start in single-digit
milliseconds at the CDN edge.

Better Auth's server instance needs the opposite. It reaches your database
through an ORM adapter, and every Postgres driver worth using opens a TCP
socket. Import `auth` into the proxy (directly, or transitively through a
helper that imports it) and the bundler pulls the whole adapter and driver into
an environment that cannot run them.

The transitive case is the one that bites. A file that only exports a small
`isAdminPath()` helper is safe until someone adds an import of
`@/lib/auth/session` to the top of it for an unrelated reason. Now the proxy
bundle contains the ORM.

## The wrong fix

Two tempting non-answers:

```ts
// 1. force the proxy onto the Node runtime
export const config = { runtime: "nodejs" };
```

Support for a Node-runtime proxy varies by host and version, and even where it
works you have signed up for a database round trip in front of *every* request,
including static assets that slipped through your matcher. That is a latency
tax on the whole site to save one render.

```ts
// 2. decode the cookie by hand at the edge
const payload = JSON.parse(atob(cookie.split(".")[1]));
if (payload.role === "admin") { /* ... */ }
```

This is worse than useless: it reads an unverified value out of a
client-controlled string. Anyone can write that cookie. This is not an
optimisation, it is an authorisation bypass.

## The fix

Split the job in two: a cheap, fallible presence check at the edge, and the
real check where the code can actually reach the database.

**In the proxy: does a session cookie exist?**

```ts
import { getSessionCookie } from "better-auth/cookies";
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const cookie = getSessionCookie(request);

  if (!cookie && request.nextUrl.pathname.startsWith("/admin")) {
    const url = request.nextUrl.clone();
    url.pathname = "/sign-in";
    url.searchParams.set("next", request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/admin/:path*", "/dashboard/:path*", "/settings/:path*"],
};
```

`getSessionCookie` reads the cookie by name. It does not verify the signature,
does not know whether the session was revoked, and does not know the user's
role. It answers exactly one question: is it worth rendering this page at all?

**In the page or layout: who is this, really?**

```tsx
import { requireRole } from "@/lib/auth/session";

export default async function AdminLayout({ children }: { children: ReactNode }) {
  await requireRole("admin");
  return <AdminShell>{children}</AdminShell>;
}
```

This runs on the Node runtime, verifies the signature, reads the session row (or
the signed cookie cache), and knows about bans and revocations. It is the
security boundary. The proxy is a redirect optimisation in front of it.

## Why this split is not a compromise

An expired-but-present cookie still reaches the page, and the page correctly
sends it to sign-in. A revoked session still reaches the page, and the page
correctly rejects it. The only thing the edge check can get wrong is doing a
render that turns into a redirect, which costs a few milliseconds and leaks
nothing.

The inverse arrangement (trusting the edge, skipping the render check) gets
the security wrong in exchange for the same few milliseconds.

## Two more edge traps

**The matcher that matches everything.** A matcher like `"/(.*)"` runs the proxy
for `_next/static` chunks, images and fonts. At best you pay for a function
invocation per asset; at worst a redirect breaks CSS loading and the site
renders unstyled. Exclude Next internals and anything with a file extension.

**The auth route.** Never let the proxy protect `/api/auth/**`. The callback
that completes a sign-in arrives without a session by definition, so protecting
it means no one can ever sign in: a redirect loop with no error message.

## Checking your work

- `bun run build` succeeds. A proxy bundle that reached for `node:net` fails
  here, not at runtime.
- Sign out, request a protected page, and confirm the redirect happens before
  any HTML for the shell is sent.
- Edit the session cookie in devtools to a plausible-looking but invalid value.
  The proxy lets it through, the page rejects it, and you land on sign-in. That
  is the design working, not a bug.
- Delete the proxy file entirely and confirm the protected page is *still*
  protected. If it is not, the check was in the wrong place all along.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
