React
Middleware for authentication in Next.js
Middleware filters requests, it does not authorise: how to read the cookie, verify the token and protect Server Actions too.
Key points
- Middleware filters, the page authorises: the permission is checked where the data is read.
- A cookie proves nothing: the token signature is verified on the server.
- Every check in middleware runs on every request: keep it local and fast.
Middleware reads the session cookie and redirects before the page renders, but it is not an authorisation check. The permission is verified in the page or in the action that reads the data.
What middleware can do
- Read cookies, headers and the URL, then decide whether the request may continue.
- Rewrite the path, redirect, and add headers to the response.
- Run before rendering, so it also runs on routes that do not exist.
- It cannot query a database with a Node driver, and it does not know the permissions that changed a millisecond ago.
npm install jose
A matcher, then the redirect
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const PUBLIC = ["/login", "/signup"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC.some((path) => pathname.startsWith(path))) return NextResponse.next();
if (!request.cookies.get("session")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
The matcher is part of the protection: without excluding assets, middleware runs on every image and every CSS file. Next.js 16 renamed the file to proxy.ts: the function and the matcher behave the same, only the name changed.
A cookie is not proof
A cookie takes ten seconds to create from the browser. The middleware here only checks that the cookie exists, and that is fine as a first filter: no cookie, no page. The token signature is a separate check, done on the server with a key the client does not hold.
// lib/session.ts
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function verifySession(token?: string) {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: string; role: string };
} catch {
return null;
}
}
Verify where the data is read
The page does not trust the fact that middleware ran: it repeats the check and scopes the query to the session user. If someone changes the matcher tomorrow, the check survives. That is the difference between a gate and a fence.
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifySession } from "@/lib/session";
export default async function DashboardPage() {
const session = await verifySession((await cookies()).get("session")?.value);
if (!session) redirect("/login");
const invoices = await db.invoice.findMany({ where: { userId: session.userId } });
return ;
}
The cost of running everywhere
Middleware runs on every request that matches, before the route exists. An external fetch or a database query in there slows every page on the site, including the public ones. Keep the check local: a cookie, a header, a string comparison.
A login block is not a session system
The login and authentication blocks give you a form, states and markup, not token handling. The Sign In record shows the visible half of a complete flow. You build the session: signature, expiry, revocation.
Mistakes you see in production
- Protecting pages with middleware and leaving Server Actions open: the action can be called without visiting the page.
- Putting the user query inside middleware: every page response then depends on the database.
- Forgetting
/loginamong the public paths: the redirect points at a page that redirects. - Treating the role written in the cookie as final: the cookie belongs to the client until you verify it.
One limitation to name
Middleware cannot revoke a session that was already issued. A valid token stays valid until it expires, even after a logout or a password change, unless the page consults a revocation list. Keep the expiry short and put revocation where the data is read.
Ready-made sign-in blocks are compared in the best authentication blocks; the server/client boundary is explained in a server component importing a hook. The 21st.dev vs shadcnblocks comparison helps you decide where to copy the form from.
Frequently asked questions
Is middleware enough to protect a page?
No. It filters requests without a cookie, but token and permission checks must happen in the page or in the action that reads the data.
Why can middleware not read the database?
Because it runs on a light runtime without a Node driver. A query there slows every request and is often not even available.
How do I avoid a redirect loop on login?
Put the login page and the other public pages in an excluded list that you check before redirecting.
Should Server Actions be protected too?
Yes. An action is a POST endpoint: the caller never passes through the page middleware, so repeat the check inside the action.
Middleware or proxy in Next.js 16?
They are the same thing: the file was renamed proxy.ts, and the function and matcher are unchanged.