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.
Your mission
Section titled “Your mission”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.
/dashboard redirects to /sign-in?next=%2Fdashboard, and signing in returns to /dashboard./sign-in or /sign-up is redirected to /dashboard without the form ever rendering.session row for that token from Postgres and redirects to /sign-in; refreshing /dashboard afterward redirects again./dashboard renders a nav strip showing the user’s email alongside a sign-out button, with the user’s name in the page body.__Host- session cookie — DevTools shows it gone.auth.api.getSession.Coding time
Section titled “Coding time”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.
The proxy: cookie presence, nothing more
Section titled “The proxy: cookie presence, nothing more”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.
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.
The layout: the validating read
Section titled “The layout: the validating read”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.
Sign-out: the revocation
Section titled “Sign-out: the revocation”'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.
The dashboard: a free second read
Section titled “The dashboard: a free second read”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.
How a request flows through both layers
Section titled “How a request flows through both layers”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 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.
The canonical guide for this exact pattern: proxy optimistic checks, the matcher, a Data Access Layer, and React cache dedupe.
getSessionCookie, the proxy pattern, and signOut wired the way this lesson uses them.
Why the export must be named proxy, plus the matcher config reference for the paths the gate runs on.
signOut and revocation, plus the cookie cache behind the five-minute window the dashboard read leans on.
Before you point a real domain at this, three things stay on the list — named here, built later.
- Rotate
BETTER_AUTH_SECRETon 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.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 5The 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.
/dashboard redirects to /sign-in?next=%2Fdashboard; signing in lands you back on /dashboard./sign-in bounces straight to /dashboard without the form rendering.session row gone, DevTools → Application → Cookies shows the session cookie cleared, and refreshing /dashboard redirects to sign-in again.proxy.ts imports getSessionCookie only — no auth import, no auth.api.getSession call anywhere in the file./sign-in once the five-minute cookie-cache window lapses — the active-sessions trade-off the sessions chapter unpacks.