Skip to content
Chapter 54Lesson 1

The two-layer gate in proxy.ts

Protect Next.js routes with Better Auth by splitting the work into a cheap cookie-presence check in proxy.ts and an authoritative session validation at the layout.

A signed-out user types https://app.example.com/dashboard into the address bar and hits Enter. There is no session. Somewhere between that keystroke and a rendered page, your app has to decide to send them to /sign-in instead, and that decision is the whole subject of this lesson.

An experienced engineer looking at that moment asks three questions before writing a line of code. Where does the redirect-to-sign-in decision live? What is that code allowed to read to make the call? And the one juniors skip: what is it explicitly not allowed to do? By the end of this lesson you’ll have a production-shaped proxy.ts that answers all three, and you’ll be able to defend each answer.

You already stood up a version of this file. Back in Reading the session everywhere with one call shape, you wrote a minimum gate, just enough for the smoke test to have somewhere to redirect. It took one deliberate shortcut. This lesson rebuilds that gate to the shape you’d actually ship, and fixes the shortcut along the way.

The rest of the lesson hangs on a single idea: protecting a route is not one check. It’s two checks, living in two different places, each answering a different question.

The first layer is the proxy. Its question is coarse and cheap: “Is there a session-shaped cookie on this request at all?” That’s the entire job. No cookie, redirect to sign-in. Cookie present, let the request continue. The proxy never asks whether the session is valid, never asks who the user is, and never asks whether they’re allowed to do anything. It runs on every matched request, including ones the browser fires speculatively before the user has even clicked, so it has to be fast and simple.

The second layer is the layout or Server Action the request is actually heading for. Its question is fine and authoritative: “Is this session valid, who does it belong to, and may that person do the specific thing they’re asking for?” This is where the cookie gets validated, where the user’s identity is resolved, and where every authorization decision is made.

So the proxy guards the perimeter and the layout guards the door. Hold onto that image, because every other decision in this lesson follows from it.

One rule falls straight out of the split, and it’s the most important one to remember: authorization decisions never live in the proxy. “Is this user an admin?” “Does this user belong to the org that owns this invoice?” Those are layer-two questions, every time. You met the general version of this back in proxy.ts and the matcher: the proxy is a fast gate, and the route enforces the real check. Here it gets specific for auth.

To see why the rule matters, picture breaking it. Say you put a role check in the proxy, if (path.startsWith('/admin') && role !== 'admin') redirect('/'). It works. It even feels efficient: one place, caught early. But now the proxy is your security model. The day someone adds /admin/billing/export and forgets to extend the matcher, that route ships with no gate at all, and because the proxy was the only thing standing guard, the data is simply public. One forgotten line leaks data, with nothing to catch it. When the validating check lives at the door instead, a missed perimeter entry is an inconvenience rather than a breach: the layout re-checks regardless, so the leak never happens. That redundancy is the entire point. It is called defense in depth, and the proxy is only ever its cheap outer ring.

The diagram below traces a single request for /dashboard through both layers so you can see where each question gets answered.

%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor, .actor tspan { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 16px !important; } .labelText, .labelText tspan, .loopText, .loopText tspan { font-size: 15px !important; }'} }%%
sequenceDiagram
    participant B as Browser
    participant P as proxy.ts
    participant L as Layout (+ DB)

    B->>P: GET /dashboard
    Note over P: reads the cookie only — no DB

    alt session cookie present
        P-->>B: NextResponse.next() — pass through
        B->>L: render /dashboard
        rect rgba(56, 189, 248, 0.14)
            Note over L: requireUser() — re-validates against<br/>DB / cookie cache EVERY time
            alt session valid
                L-->>B: rendered page
            else forged / stale / user deleted
                L-->>B: redirect /sign-in
            end
        end
    else no session cookie
        P-->>B: redirect /sign-in?next=/dashboard
    end
One request through both layers of the gate (Chapter 54). The proxy's pass-through means only 'a cookie was present' — the layout's read is the arrow that actually decides, and it can still redirect.

Look at that bottom-right branch again, the one inside the layout. Even after the proxy waves a request through, the layout can still redirect it, because the cookie might be forged, stale, or belong to a user who was deleted five minutes ago. The proxy saying “there’s a cookie” and the session actually being valid are two different facts. The most common misconception about this whole setup is “the proxy already checked, so the layout can trust it.” It can’t, and we’ll come back to exactly why at the end of the lesson.

This is the shortcut your earlier gate took, and fixing it is the most important why in the lesson, so it’s worth walking through carefully.

The minimum gate from the Better Auth setup called auth.api.getSession inside the proxy. That genuinely works: it reads a real, validated session. But it doesn’t scale, and the reason is a single word, prefetch . Prefetching is Next.js fetching a route’s data and React payload before you navigate to it, on hover or when a link scrolls into view, so the page feels instant when you finally click. It’s a feature you want. But getSession round-trips to Postgres (or, on a good day, to a short-lived cookie cache), and the proxy runs on every matched request. Next.js prefetches protected routes too. So the moment a user’s mouse drifts over a sidebar link to /dashboard, your proxy fires a database query. Multiply that by every link in the nav, every hover, every user, and you’ve turned idle mouse movement into database load.

The fix is to read less. Inside the proxy, don’t ask whether the session is valid; ask only whether a session-shaped cookie exists. Better Auth ships a helper built for exactly this: getSessionCookie, from better-auth/cookies. It’s pure cookie parsing. It looks at the request’s Cookie header, checks for the session cookie by name, and returns it or null. Zero IO, nothing to await. This is an optimistic check : a cheap, possibly-stale read where you trade a little correctness for a lot of speed, knowing an authoritative check backs it up later. The question shrinks from “is this session valid?” to “is there a cookie here at all?”

There’s one more thing worth getting straight, because it trips up a lot of people reading 2026 codebases. You might have heard “never hit the database from middleware” stated as a hard technical limit. It used to be closer to one: the old middleware.ts ran on the Edge runtime, where a normal database driver couldn’t follow. That era is over. As you saw in proxy.ts and the matcher, proxy.ts runs on the Node runtime in Next.js 16, and you can’t switch it to Edge. That means the proxy could call your database; the wiring would work fine. So “no DB in the proxy” is no longer a capability limit. It’s a performance decision, forced by prefetch. The rule is the same as before, but the reason is different, and knowing the difference keeps you from applying it where it doesn’t belong.

Here’s the read itself, just the import and the check for now. The rest of the file comes together at the end.

import { getSessionCookie } from 'better-auth/cookies';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const sessionCookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const isSignedIn = sessionCookie != null;

The imports. getSessionCookie is cookie parsing and nothing else. SESSION_COOKIE_PREFIX is exported from lib/auth.ts, the same file that configures the cookie, so the prefix is written once and the proxy and the auth instance can never drift apart.

import { getSessionCookie } from 'better-auth/cookies';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const sessionCookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const isSignedIn = sessionCookie != null;

The call. Notice what’s missing: no await, no database client, no query. It reads the Cookie header off the incoming request and returns the matching cookie’s value or null.

import { getSessionCookie } from 'better-auth/cookies';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const sessionCookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const isSignedIn = sessionCookie != null;

The trap. getSessionCookie defaults its prefix to 'better-auth.', and this stack uses __Host-. Skip the explicit prefix and the helper looks for a cookie that isn’t there, returns null on a perfectly valid session, and the proxy redirects a signed-in user back to sign-in, again and again. The cookie is right there in the browser; the proxy just can’t see it. Passing the constant is mandatory.

import { getSessionCookie } from 'better-auth/cookies';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const sessionCookie = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
const isSignedIn = sessionCookie != null;

The name says how much you actually know. Not session, not user, but isSignedIn. All this line establishes is that a cookie is present. Validity is somebody else’s job.

1 / 1

Step three is the most bug-prone line in this entire file, and it fails silently: no error, no warning, just a redirect loop that looks like a Better Auth bug when it’s really a one-word omission. When you read a “the cookie exists but the proxy keeps redirecting” report, this is almost always the cause. Pass the prefix.

The proxy shouldn’t run on every request in your app, only the ones worth gating. That’s the matcher’s job, and you already know its syntax from proxy.ts and the matcher: path strings, arrays, and has/missing conditions, all aimed at keeping the proxy off requests it has no business inspecting. This section isn’t about syntax. It’s about a strategy decision the syntax can’t make for you, one an experienced engineer makes deliberately and writes down.

There are two ways to draw the line, and they’re mirror images.

The first is an allowlist: name the protected sections explicitly, as in ['/dashboard/:path*', '/settings/:path*', '/billing/:path*']. The proxy runs on those and nowhere else. This is the right call when a small, known set of sections is gated and most of the app is public: the marketing pages, the pricing page, the blog. Its defining trait, and its risk, is that a route you forget to list is public by default.

The second is matchall-minus-public: run on everything, then carve out the handful of public paths, such as /, /sign-in, /sign-up, the static assets, /api/auth/:path*, and _next. This is the right call when most of the app sits behind auth and the public pages are the exceptions. Its defining trait is the inverse: a route you forget to list is protected by default.

Those two defining traits are the whole decision, so look at them again. Each strategy has a direction it fails in. The allowlist fails open: forget an entry and a route leaks. Matchall-minus fails closed: forget an entry and a route is locked, which you notice immediately because the page is broken, so nobody gets hurt. Neither is wrong. The experienced call is simpler and stricter than picking the “better” one.

Either strategy is correct, but mixing them is the failure mode. A partial allowlist, where someone started listing exceptions inside what was meant to be an allowlist or vice versa, gives you the worst of both: routes that are unprotected by default and a config nobody can reason about. Pick one strategy, leave a one-line comment at the matcher saying which and why, and hold the line on it for the life of the codebase. The cost of drifting isn’t visible the day you do it. It shows up six months later, when a route someone added ships with no gate and no one notices until it’s in the wild.

Notice how this loops back to the first rule. The allowlist’s failure mode, a forgotten entry leaving a route ungated, is exactly why authorization has to live at the door, not the perimeter. A gate that can fail open must never be the only thing between a user and the data. The matcher is allowed to be imperfect precisely because the layout isn’t.

Walk the decision the way you’d actually reason through it on a new project.

Which matcher strategy?

Here are the two config.matcher objects side by side. The line below each names which direction it fails, and that one phrase is the thing to internalize, not the regex.

export const config = {
// Allowlist: only these sections are gated. New routes are PUBLIC
// by default — add every new protected section here.
matcher: ['/dashboard/:path*', '/settings/:path*', '/billing/:path*'],
};

Fails open. A protected section you forget to list ships ungated. Correct for marketing-heavy apps where most routes are genuinely public; the cost is the discipline of remembering every new gated section.

Sending the user back where they came from

Section titled “Sending the user back where they came from”

Redirecting a signed-out user to sign-in is correct but blunt: it throws away where they were trying to go. A user who clicked a deep link to /billing/invoices?status=open, got bounced to sign-in, and landed on a bare /dashboard after authenticating has every right to be annoyed. The fix is to remember the destination across the round-trip.

The proxy does that by tucking the original path into the redirect as a query parameter:

const next = encodeURIComponent(pathname + search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));

Two details earn their keep. You append search as well as pathname, so ?status=open survives; without it the user lands on the bare list, not their filtered view. And you encodeURIComponent the value, because it’s about to ride inside another URL’s query string, so the slashes and ampersands need to travel as data, not structure.

Now stay honest about scope. That next value makes a full round trip: the proxy writes it into a URL, the browser carries it, and the sign-in page reads it back out and redirects there after authenticating. A value that gets read out of user-controllable input and fed into a redirect is the textbook open redirect : redirect to a URL pulled from input an attacker can shape, and you’ll happily bounce your users to a phishing page wearing your domain in the referrer. You met this threat and its fix in Rewrites and redirects in proxy.ts, which owns that surface. The contract for this whole stack is a helper called safeNext, living in lib/redirects.ts: it takes the raw next and returns it only if it’s a safe in-app path, falling back to /dashboard for anything suspicious. Never redirect(searchParams.get('next')) raw. An invalid next resolves to /dashboard, never to the attacker’s value.

Be precise about who does what, because this is the easiest place in the lesson to overreach. The proxy writes next, and the sign-in form reads and validates it. Those are two different files with two different jobs. This lesson is the writer; it has no business validating anything. The reader side is one line on the form, shown here only to close the loop in your head:

// in the sign-in form's success path — the form itself lives elsewhere
redirect(safeNext(next));

That’s the entire consumer side from this lesson’s point of view. The actual sign-in form is the one from Password sign-in, and safeNext’s implementation belongs to that earlier App Router lesson on rewrites and redirects. The diagram below shows the whole trip in one glance. The thing to take from it is that next passes through attacker-reachable surface on its way around, which is precisely why the validation step is not optional.

where they were headed Protected URL /billing?status=open a signed-out user hits a deep link
proxy writes next Proxy redirect /sign-in?next=%2Fbilling%3Fstatus%3Dopen original path URL-encoded into the query
attacker-reachable Sign-in form reads next from the URL user authenticates · next is user-controllable here
the reason phase 3 is safe safeNext(next) safe in-app path? keep it anything else falls back to /dashboard
round-trip complete Land /billing?status=open or /dashboard, if next was unsafe
The `?next=` round-trip — validated on the way back (Chapter 54). Phase 3 is the only attacker-reachable surface: `next` is user-controlled there, which is exactly why phase 4's `safeNext` is not optional.

The gate so far points one direction: it keeps signed-out users out of protected routes. There’s a mirror-image case worth handling in the same file, keeping signed-in users off the auth pages.

A user who’s already authenticated has no reason to see /sign-in, so land them on /dashboard instead. There are two reasons, one obvious and one subtle. The obvious one is plain UX: showing a sign-in form to someone who’s signed in is confusing and slightly insulting. The subtle one is a faint security smell: in some setups, a stray submit on that form can churn the user’s current session for no reason. Either way, the form shouldn’t be reachable.

The rule is the inverse of the one you already have, using the exact same cookie read: if the matched path is an auth page and a session cookie is present, redirect to /dashboard. Same getSessionCookie, opposite condition. It’s two lines, and it lives in the same proxy.

Putting both gates in one file brings one specific hazard, so name it now. If the auth-page rule and the protected-route rule ever disagree about a path, one wanting to redirect it to /dashboard and the other to /sign-in, you get a redirect loop, and a redirect loop takes the page down entirely. What prevents it is a small matrix you run in your head for every path the proxy matches: signed-in here, what happens? Signed-out here, what happens? Four cells, no surprises. When both directions live in one file, that matrix is what keeps them from fighting each other.

The auth gate won’t be the proxy’s only resident forever. Down the road this same file may also do internationalization routing (a later chapter wires it up with next-intl), feature-flag bucketing, or A/B test routing. The constraint that shapes how you handle that is this: Next.js runs exactly one proxy.ts. You don’t get a second file, so every cross-cutting request concern has to coexist in this one.

What keeps that from turning into a mess is to make each responsibility a small, named function that either returns a response or declines, then chain them. The first one to return a NextResponse wins, and if they all decline, the request passes through. Sketch the auth gate as one such function:

const authGate = (request: NextRequest): NextResponse | undefined => {
// a redirect to short-circuit, or undefined to defer to the next gate
};

The proxy function then calls the gates in order and returns the first response. There’s an ordering rule worth noting now even though you won’t build it here: when next-intl enters the picture, its createMiddleware runs before the auth gate, because it needs to resolve the locale first, and the file still has to export a function named proxy. The i18n work slots into exactly this structure, so leaving the seam here means it drops in cleanly later instead of forcing a rewrite.

Two restraints are worth a sentence each, because they’re reflexes a junior reaches for and gets wrong.

The first is to not touch the session cookie on every request. It’s tempting to refresh the cookie’s expiry in the proxy so sessions slide forward on activity. Don’t. Better Auth’s sliding renewal, the updateAge setting you configured in the session-lifetimes lesson, already extends the session on the next mutating call. A proxy-level cookie write just adds a write to every single page load for a renewal that’s already happening where it should.

The second is a sharper edge. Across this course you’ll follow a fail-closed discipline: a security check that throws should deny access, never silently allow it. That seems to clash with the advice to wrap the proxy so a throw doesn’t 500 every matched request. It doesn’t, once you see the resolution: the cookie-presence read is pure parsing, so it can’t really throw. The practical rule is to keep the proxy too simple to throw. If you ever add logic that can fail, its failure path defaults to redirect-to-sign-in, not pass-through. When in doubt, the gate closes.

Here is the complete proxy.ts, with every decision from this lesson assembled. It’s around two dozen lines, and we’ll walk it one decision at a time.

import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const AUTH_PAGES = ['/sign-in', '/sign-up'];
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isSignedIn =
getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX }) != null;
const isAuthPage = AUTH_PAGES.some((page) => pathname.startsWith(page));
if (isAuthPage) {
return isSignedIn
? NextResponse.redirect(new URL('/dashboard', request.url))
: NextResponse.next();
}
if (!isSignedIn) {
const next = encodeURIComponent(pathname + search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
return NextResponse.next();
}
// Allowlist strategy: these sections are gated; everything else is public.
// New protected sections MUST be added here — see lesson on matcher strategy.
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*',
'/billing/:path*',
'/sign-in',
'/sign-up',
],
};

The imports and the name to come. The export must be named proxy, because Next.js 16 dispatches on that exact name. SESSION_COOKIE_PREFIX comes from lib/auth.ts so the prefix can’t drift from the auth config.

import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const AUTH_PAGES = ['/sign-in', '/sign-up'];
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isSignedIn =
getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX }) != null;
const isAuthPage = AUTH_PAGES.some((page) => pathname.startsWith(page));
if (isAuthPage) {
return isSignedIn
? NextResponse.redirect(new URL('/dashboard', request.url))
: NextResponse.next();
}
if (!isSignedIn) {
const next = encodeURIComponent(pathname + search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
return NextResponse.next();
}
// Allowlist strategy: these sections are gated; everything else is public.
// New protected sections MUST be added here — see lesson on matcher strategy.
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*',
'/billing/:path*',
'/sign-in',
'/sign-up',
],
};

The cookie-presence read: pure parsing, no DB. The prefix is passed explicitly because the default 'better-auth.' would silently miss this stack’s __Host- cookie and loop a signed-in user back to sign-in.

import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const AUTH_PAGES = ['/sign-in', '/sign-up'];
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isSignedIn =
getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX }) != null;
const isAuthPage = AUTH_PAGES.some((page) => pathname.startsWith(page));
if (isAuthPage) {
return isSignedIn
? NextResponse.redirect(new URL('/dashboard', request.url))
: NextResponse.next();
}
if (!isSignedIn) {
const next = encodeURIComponent(pathname + search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
return NextResponse.next();
}
// Allowlist strategy: these sections are gated; everything else is public.
// New protected sections MUST be added here — see lesson on matcher strategy.
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*',
'/billing/:path*',
'/sign-in',
'/sign-up',
],
};

The inverse gate. On an auth page, a signed-in user is bounced to /dashboard, and a signed-out user is allowed to see the form. This is the mirror of the protected-route gate, living in the same file.

import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const AUTH_PAGES = ['/sign-in', '/sign-up'];
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isSignedIn =
getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX }) != null;
const isAuthPage = AUTH_PAGES.some((page) => pathname.startsWith(page));
if (isAuthPage) {
return isSignedIn
? NextResponse.redirect(new URL('/dashboard', request.url))
: NextResponse.next();
}
if (!isSignedIn) {
const next = encodeURIComponent(pathname + search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
return NextResponse.next();
}
// Allowlist strategy: these sections are gated; everything else is public.
// New protected sections MUST be added here — see lesson on matcher strategy.
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*',
'/billing/:path*',
'/sign-in',
'/sign-up',
],
};

The protected-route gate with the round-trip. With no cookie on a non-auth page, the proxy redirects to sign-in, carrying the original path and its search string, URL-encoded, in next.

import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
const AUTH_PAGES = ['/sign-in', '/sign-up'];
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isSignedIn =
getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX }) != null;
const isAuthPage = AUTH_PAGES.some((page) => pathname.startsWith(page));
if (isAuthPage) {
return isSignedIn
? NextResponse.redirect(new URL('/dashboard', request.url))
: NextResponse.next();
}
if (!isSignedIn) {
const next = encodeURIComponent(pathname + search);
return NextResponse.redirect(new URL(`/sign-in?next=${next}`, request.url));
}
return NextResponse.next();
}
// Allowlist strategy: these sections are gated; everything else is public.
// New protected sections MUST be added here — see lesson on matcher strategy.
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*',
'/billing/:path*',
'/sign-in',
'/sign-up',
],
};

The matcher, with the comment that documents the strategy. It lists both the protected sections and the auth pages, because every gate above only fires on paths the matcher lets through, so both directions need their paths matched. The allowlist strategy is written down so the next person holds the line.

1 / 1

One placement detail that costs people an afternoon: this file lives at your project root or in src/, not under src/app/. Next.js won’t pick up a proxy.ts inside the app directory, and there’s no error to tell you it was ignored. Your gate just silently doesn’t run, every route is wide open, and everything looks fine until it very much isn’t.

Walk back to where we started: the proxy let a request through. It’s easy to read that as “approved.” It isn’t, and treating it as approval is the one mistake that quietly undoes everything in this lesson.

After the proxy passes a request, the protected layout still calls requireUser(), the helper you built in the Better Auth setup. That call is the real door. It validates the cookie against the database (or the short-lived cookie cache), and it either returns the user or redirects to /sign-in?next=.... The proxy is the perimeter; this is the door check. The perimeter waving someone through has never meant the door should open without checking.

So carry this rule forward: never assume the proxy’s cookie check guarantees a valid session. There are three concrete ways the cookie can lie. The cookie cache can be stale within its window, up to the maxAge you set back in the session-lifetimes lesson, so the cached session might already be revoked. The cookie can be forged outright: it passed presence, and it’ll fail validation. And the user behind it might have been deleted since the cookie was minted, leaving a cookie that points at nobody. In all three, the proxy says “looks fine,” and the layout’s read is the only thing that catches it. The cookie’s presence is a hint; the layout’s validation is the truth.

This isn’t a paranoid edge case, and the next two lessons in this chapter are about to prove it. Changing the password and the email and the active-sessions surface both create exactly the state where the cookie says valid but the session is supposed to be gone, on purpose, as the correct behavior. A revoked session whose cookie is still sitting in some other browser happens often, and the layout’s re-validation is what makes revocation actually mean something. The split you learned here is the floor those lessons are built on.

Before moving on, sort the two layers’ jobs by hand. This is the one distinction the whole lesson turns on, so it’s the one check worth doing.

Each responsibility belongs to exactly one layer of the gate. Sort each into the layer that owns it. Drag each item into the bucket it belongs to, then press Check.

Proxy Perimeter — cookie presence only, no DB
Layout / action Door — validates, identifies, authorizes
Redirect a signed-out user off /dashboard
Read whether a session cookie exists
Bounce a signed-in user off /sign-in
Validate the session against the database
Check whether user.role === 'admin'
Filter rows by the user’s org
Decide whether this user may delete this invoice

If any of the layout items ended up under the proxy, that’s the canonical bug in miniature: you’d have made the matcher your security model, and a forgotten matcher entry would become a leak. Re-read why authorization lives at the door, not the perimeter.

One more quick check on the line that breaks most often in practice.

Your cookies use the __Host- prefix, but you call getSessionCookie(request) with no cookiePrefix option. A genuinely signed-in user opens /dashboard. What happens?

The helper searches for a cookie under its default better-auth. prefix, never sees the __Host- one sitting in the browser, and hands back null. The proxy reads that as signed-out and redirects to /sign-in — on a user who is signed in.
Next.js fails the build because the prefix in the proxy doesn’t match the one in your auth config.
The cookie still isn’t found, but the proxy notices and falls back to a database read to validate the session anyway.
Better Auth detects the prefix mismatch at runtime and responds with a 401.

You now have a proxy.ts you could ship: cookie-presence reads with no database in the hot path, a matcher strategy you chose on purpose, the ?next= round-trip with its open-redirect protection honored at the call site, and the inverse gate folded in, all sitting on the two-layer split that keeps authorization where it belongs. The perimeter is cheap and simple by design. The door downstream is where the real check happens, and that’s where the next lessons go.