Keel

Docs / Authentication / Sessions & middleware

Sessions & middleware

Better Auth issues a session as an httpOnly, signed cookie backed by a row in the session table (lib/db/schema/auth-schema.ts). The cookie is opaque to the browser, tampering with it fails the signature check, and the real session data (user, role, expiry) only ever comes from the server reading that row.

It's proxy.ts, not middleware.ts

Next.js 16 renamed Middleware to Proxy. Same file-convention slot, same runtime, new name. If your muscle memory or training data says middleware.ts, the file you want here is proxy.ts at the repo root.

What the proxy checks

proxy.ts runs on every navigation, including prefetches, so it does the cheapest possible check: is a session cookie present. It uses getSessionCookie(request) from better-auth/cookies and never touches the database, a DB round trip on every prefetch would be wasteful.

That makes it an optimistic check, not authorization. It confirms a cookie exists, not that it's valid, unexpired, or that the user has any particular role.

What it protects

  • /app/* and /admin/*, no session cookie redirects to /auth/sign-in?redirect=<path> so the user lands back where they were headed after signing in.
  • /auth/sign-in and /auth/sign-up, if a session cookie is already present, redirect to /app (guest-only routes).

The matcher in the exported config scopes the proxy to exactly those paths, so nothing else pays the cost.

The real check is server-side

The proxy keeps unauthenticated users out of the app shell, but it is not the authorization layer. Every route that reads protected data re-checks the session server-side against the database:

  • API routes and pages resolve the session with auth.api.getSession({ headers }), wrapped as getSession() in lib/dal/teams.ts (React cache()'d to one round trip per request).
  • Team-scoped routes then call authorizeTeamAccess(teamId, permission), which confirms membership and role, see Permissions.
  • /admin/* re-checks the superadmin role in app/admin/layout.tsx, since the proxy can't read a role without a DB hit. A non-superadmin with a valid cookie clears the proxy but still gets a 403.

Why split it this way

The optimistic cookie check makes navigation fast and handles the common "not logged in at all" case at the edge. The authoritative check lives next to the data it protects, so a stolen or stale cookie can never substitute for a real session or role.

Edit this page on GitHub →Last updated Jul 22, 2026