Skip to content
Chapter 55Lesson 5

Gate the protected surface

The auth flow works now: accounts get created, emails verify, sessions issue on sign-in. But /dashboard is still wide open — type the URL while signed out and it serves the page anyway. This lesson installs the gate. By the end, /dashboard is reachable only when you are signed in, signed-in users are kept off the auth pages, and signing out deletes the session row.

The gated dashboard this lesson ships: an app nav with the signed-in user's email on the left and a Sign out button on the right, above the "Hello Ada Lovelace" greeting.

The gate is two layers. The first is the proxy in proxy.ts, which runs before any route renders: it checks for a session cookie and redirects on what it finds. No cookie on /dashboard sends you to /sign-in; a cookie on /sign-in sends you to /dashboard. The proxy must not read the database. It runs on every matched request, including the prefetches the router fires as a user hovers links, so a getSession call here would bill a round trip to Postgres for traffic the user never sees. Cookie presence is all the proxy gets to know.

The second layer is the validating read, one level deeper in the protected layout.tsx. The proxy trusts the cookie’s presence; the layout verifies the session is real. That gap matters because a cookie can outlive its session: the browser still holds it after the row behind it is gone. The proxy waves that cookie through, then the layout calls requireUser() and redirects when the session no longer resolves. This is defense in depth: the cheap check on the hot path, the authoritative check where a render actually happens.

Sign-out is a Server Action wired as <form action={…}> rather than an onClick fetch, so it is progressively enhanced: the button works and the redirect lands even without client JS. The single auth.api.signOut call clears the cookie and deletes the session row at once, and the deletion is the point. The session is the opaque server-stored row, so its absence is the revocation. Once the row is gone, the same cookie value resolves to nothing, and the layout’s validating read turns the next /dashboard visit straight back to /sign-in.

Keep the matcher explicit. It lists exactly the paths the proxy runs on, and it pairs with the layout: every protected segment you add later needs both an entry in the matcher and a requireUser() call in its layout. The matcher alone is not the gate; it only decides where the proxy looks, while the validating read is what refuses a stale session.

A signed-out request to /dashboard redirects to /sign-in?next=%2Fdashboard, and signing in returns to /dashboard.
tested
A signed-in request to /sign-in or /sign-up is redirected to /dashboard without the form ever rendering.
tested
Clicking sign-out deletes the session row for that token from Postgres and redirects to /sign-in; refreshing /dashboard afterward redirects again.
tested
A signed-in /dashboard renders a nav strip showing the user’s email alongside a sign-out button, with the user’s name in the page body.
untested
Sign-out clears the __Host- session cookie — DevTools shows it gone.
untested
The proxy decides on cookie presence only — it never imports or calls auth.api.getSession.
untested

Wire the proxy, the protected layout, the sign-out action, and the dashboard read against the brief and the tests, then open the walkthrough to compare.

Reference solution and walkthrough

Four files: two are the gate, two are the surface behind it. Take them in the order a request travels — proxy, layout, sign-out, page.

import { getSessionCookie } from 'better-auth/cookies';
import { type NextRequest, NextResponse } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export async function proxy(request: NextRequest) {
// cookiePrefix is mandatory — the better-auth default silently misses the
// __Host- cookie. This is presence-only; no authz decision lives here.
const cookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const path = request.nextUrl.pathname;
const isProtected = path.startsWith('/dashboard');
const isAuthPage = path === '/sign-in' || path === '/sign-up';
if (isProtected && !cookie) {
const next = encodeURIComponent(path + request.nextUrl.search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
if (isAuthPage && cookie) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/sign-in', '/sign-up'],
};

This is the proxy’s entire database contact: none. getSessionCookie reads the cookie off the request and returns its value or undefined — it never validates the session against Postgres. There is no import { auth } and no getSession call anywhere in the file, because reading the database is the layout’s job.

import { getSessionCookie } from 'better-auth/cookies';
import { type NextRequest, NextResponse } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export async function proxy(request: NextRequest) {
// cookiePrefix is mandatory — the better-auth default silently misses the
// __Host- cookie. This is presence-only; no authz decision lives here.
const cookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const path = request.nextUrl.pathname;
const isProtected = path.startsWith('/dashboard');
const isAuthPage = path === '/sign-in' || path === '/sign-up';
if (isProtected && !cookie) {
const next = encodeURIComponent(path + request.nextUrl.search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
if (isAuthPage && cookie) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/sign-in', '/sign-up'],
};

One function runs both gates. A protected path with no cookie bounces to sign-in carrying the original path in ?next=; an auth page with a cookie bounces a signed-in user off a form they have no business seeing. Everything else falls through to NextResponse.next().

import { getSessionCookie } from 'better-auth/cookies';
import { type NextRequest, NextResponse } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export async function proxy(request: NextRequest) {
// cookiePrefix is mandatory — the better-auth default silently misses the
// __Host- cookie. This is presence-only; no authz decision lives here.
const cookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const path = request.nextUrl.pathname;
const isProtected = path.startsWith('/dashboard');
const isAuthPage = path === '/sign-in' || path === '/sign-up';
if (isProtected && !cookie) {
const next = encodeURIComponent(path + request.nextUrl.search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
if (isAuthPage && cookie) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/sign-in', '/sign-up'],
};

The matcher keeps it cheap: the proxy never runs on routes outside this list. It pairs with the layout — adding a protected segment later means an entry here and a requireUser() call in that segment’s layout.

1 / 1

The export must be named proxy. Next.js 16 dispatches the request-time gate by that exact name; rename it and the file silently does nothing.

The cookiePrefix argument is mandatory, and the canonical silent failure is leaving it off. getSessionCookie’s default does not match the __Host--prefixed cookie the auth instance sets in production. Locally everything looks fine — the dev prefix is the plain better-auth, which the default finds — but on deploy every signed-in user’s cookie goes unseen, the proxy reads them as signed out, and the inverse gate bounces them off /sign-in into a redirect loop. Importing SESSION_COOKIE_PREFIX from lib/auth.ts, the one place the prefix is declared, keeps the proxy reading the cookie under the same name the auth instance wrote it; re-typing the literal here would let the two drift.

src/app/(protected)/layout.tsx
import { type ReactNode, Suspense } from 'react';
import { signOutAction } from '@/app/(protected)/sign-out-action';
import { Button } from '@/components/ui/button';
import { requireUser } from '@/lib/auth';
const AppNav = async () => {
// The layout's own request-time read must sit under <Suspense> — a co-located
// loading.tsx covers the children, not the layout body.
const user = await requireUser('/dashboard');
return (
<nav
data-testid="app-nav"
className="flex items-center justify-between border-b px-6 py-4"
>
<span data-testid="nav-user-email" className="text-sm font-medium">
{user.email}
</span>
<form action={signOutAction}>
<Button type="submit" variant="outline" data-testid="sign-out-button">
Sign out
</Button>
</form>
</nav>
);
};
export default async function ProtectedLayout({
children,
}: {
children: ReactNode;
}) {
return (
<>
<Suspense>
<AppNav />
</Suspense>
<main>{children}</main>
</>
);
}

This is the layer that catches what the proxy can’t see. requireUser('/dashboard') calls auth.api.getSession for real, and if no session resolves it redirects to /sign-in?next=/dashboard — a stale cookie the proxy honored dies right here. The '/dashboard' argument is the path it stuffs into ?next=, so a session that expires mid-visit still sends the user back to where they were.

Why an inner AppNav under <Suspense> instead of await requireUser() at the top of the layout? Because of the loading.tsx beside the dashboard page. A co-located loading file is the Suspense fallback for the route’s children — the streamed page content — not for the layout shell. Run the gate’s await in the layout body and it blocks the shell from rendering, so the skeleton never shows and the user stares at nothing until the session read resolves. Pushing the read into AppNav and wrapping that one component in its own <Suspense> lets the nav resolve on its own schedule while the dashboard streams behind its skeleton.

The nav renders {user.email} beside a sign-out form. That settles the nav-shape requirement: the email lives in the nav strip, the user’s name goes in the page body (next file), and sign-out is a form, not a button with an onClick. A <form action={signOutAction}> posts to the Server Action directly; it is progressively enhanced, so it works without client JS and the post-action redirect lands as a real navigation rather than a client-side router push that needs hydration to fire.

src/app/(protected)/sign-out-action.ts
'use server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export const signOutAction = async () => {
// Deleting the session row is the revocation — the cookie clear follows.
await auth.api.signOut({ headers: await headers() });
redirect('/sign-in');
};

One call does the work. auth.api.signOut reads the session cookie from the request headers, deletes the matching session row, and writes the cookie-clearing Set-Cookie — the nextCookies() plugin you wired into the auth instance lands that header on the response. Then redirect('/sign-in') sends the signed-out user away.

The deletion is the whole revocation. The session is opaque and server-stored, so the cookie value is just a lookup key into the session table; delete the row and the key points at nothing — no token to wait out, no expiry to honor. The next request still presents the cookie and sails through the proxy, but dies at the layout’s validating read, which finds no row and redirects. Revocation is instant at the database, and the layer that reads the database is the one that enforces it.

src/app/(protected)/dashboard/page.tsx
import { getCurrentUser } from '@/lib/auth';
const DashboardPage = async () => {
// Second read in the request — served from the React-cache dedupe, no extra
// DB round trip.
const user = await getCurrentUser();
return (
<section
data-testid="dashboard-page"
className="mx-auto max-w-2xl px-6 py-16"
>
<h1 className="text-2xl font-semibold">Hello {user?.name}</h1>
<dl className="mt-6 text-sm text-muted-foreground">
<dt className="font-medium text-foreground">Email</dt>
<dd>{user?.email}</dd>
</dl>
</section>
);
};
export default DashboardPage;

The layout already read the session to gate the route, and the page reads it again with getCurrentUser() to greet the user by name. That looks like two trips to Postgres for one request, but it isn’t: the read ladder wraps getSession in React’s cache, which dedupes calls within a single request. The layout’s requireUser() does the read; the page’s getCurrentUser() hits the cached result. One render, one session read, two consumers — so you can call for the user wherever you need it and the request pays for the lookup once.

The user?. optional chaining is here only to satisfy the type: getCurrentUser returns User | null. The layout’s gate guarantees a user is present by the time the page renders, so the null branch never fires.

Watch the Drizzle query log on a signed-in /dashboard request and most of the time you will see zero session reads. The auth instance runs a five-minute cookie cache: within that window, getSession resolves the session from a signed value in the cookie without touching Postgres. The dedupe above only matters in the worst case, when the cache window has lapsed and the session must be re-read from the row.

flowchart LR
  req(["Request"])

  proxy{"<b>Layer 1 · proxy.ts</b><br/>cookie present?<br/><i>no DB read</i>"}
  layout{"<b>Layer 2 · layout</b><br/>requireUser()<br/><i>session valid?</i>"}

  signin["redirect<br/>/sign-in?next="]
  dash["redirect<br/>/dashboard"]
  stale["redirect<br/>/sign-in"]
  render(["render page"])

  req --> proxy
  proxy -- "protected,<br/>no cookie" --> signin
  proxy -- "auth page,<br/>has cookie" --> dash
  proxy -- "otherwise" --> layout
  layout -- "no session<br/>(stale cookie)" --> stale
  layout -- "valid" --> render

  class req edge
  class proxy proxy
  class layout read
  class signin,dash,stale stop
  class render route
  classDef edge fill:#1f2937,stroke:#94a3b8,color:#f8fafc
  classDef proxy fill:#dbeafe,stroke:#1d4ed8,color:#111,stroke-width:2px
  classDef read fill:#ede9fe,stroke:#6d28d9,color:#111,stroke-width:2px
  classDef stop fill:#fee2e2,stroke:#b91c1c,color:#111
  classDef route fill:#fef9c3,stroke:#a16207,color:#111,stroke-width:2px
The two-layer request path. Layer 1, the proxy, decides on cookie *presence* alone and never touches the database; its two redirect branches are the forward gate and the inverse gate. A pass-through reaches Layer 2, the layout's `requireUser()`, the authoritative read that catches a stale cookie the proxy waved through.

The proxy reads the cookie and never the database; the layout reads the session and is where a stale cookie finally gets caught. You apply pieces here that earlier lessons built: the forward and inverse gates and the ?next= round trip from the request-time gate chapter, the safeNext guard from last lesson’s sign-in action, and the opaque server-stored session model made concrete by the sign-out delete.

Before you point a real domain at this, three things stay on the list — named here, built later.

  • Rotate BETTER_AUTH_SECRET on a cadence and on every staff turnover. It signs the session cookie cache, so a leaked secret is a forged-session risk and rotation is the containment.
  • Wire Resend’s bounce and complaint webhooks to write email_suppressions, so verification mail to a bouncing address stops going out before it scorches the sending domain’s reputation.
  • Add rate limits to the sign-in, sign-up, and verify-resend endpoints before any of this reaches a public URL — an unthrottled credential endpoint is a brute-force and enumeration target.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 5

The suite drives the proxy with synthetic requests and reads the rows the sign-out action leaves behind. Expect every test to pass.

✓ tests/lessons/Lesson 5.test.ts (6 tests)
Test Files 1 passed (1)
Tests 6 passed (6)

Synthetic requests can’t see everything, so confirm the rest by running the flow in a real browser.

Signed out, visiting /dashboard redirects to /sign-in?next=%2Fdashboard; signing in lands you back on /dashboard.
untested
Signed in, visiting /sign-in bounces straight to /dashboard without the form rendering.
untested
After sign-out, Studio shows the session row gone, DevTools → Application → Cookies shows the session cookie cleared, and refreshing /dashboard redirects to sign-in again.
untested
proxy.ts imports getSessionCookie only — no auth import, no auth.api.getSession call anywhere in the file.
untested
With two browsers signed in as the same account, signing out of one and refreshing the other redirects it to /sign-in once the five-minute cookie-cache window lapses — the active-sessions trade-off the sessions chapter unpacks.
untested