Middleware redirect loop after login in the App Router
Your middleware sees no cookie and 307s to /login. Your login page sees a session and bounces back. Traced hop by hop, plus the matcher and cookie rules that decide where it turns around.
In short
ERR_TOO_MANY_REDIRECTS after sign-in means middleware and your login page are reading different storage. Middleware only sees cookies on the incoming request, so a session in localStorage is invisible to it. Fix the matcher so it cannot catch /login or the auth callback, and check the cookie, do not verify it.
Chrome says ERR_TOO_MANY_REDIRECTS. The network tab shows the same two rows
over and over, 307 on /dashboard, 200 on /login, 307 on /dashboard,
until it stops at twenty hops and gives up. Sometimes the address bar never
moves at all and the page just spins, with the repeating 307 chain hiding in
the RSC requests instead.
It is one loop with two symptoms, and the fix is not "add a check to the login page". The fix is realising that your middleware and your login page are reading two different things and each one is right about what it can see.
Here is a single request, followed all the way around.
Hop 1: GET /dashboard
What middleware saw: an incoming request with a Cookie header that does
not contain bb-session.
Why it decided that: because it is true. The user signed in a second ago,
but the session id went to localStorage, which is exactly what our own
sessions doc shows as the first option:
callbacks: {
getSession: async () => localStorage.getItem('sessionId'),
// ...
}Middleware runs at the edge, on the request, before any of your JavaScript
exists. It gets the URL, the headers, and whatever cookies the browser chose to
send. That is the entire input. localStorage is not on the list and never
will be, so the guard does the only thing available to it:
return NextResponse.redirect(new URL('/login', request.url));NextResponse.redirect defaults to 307. That is the first row in your
network tab, and it is correct behaviour. Nothing has gone wrong yet.
Hop 2: GET /login
What middleware saw: /login, still no cookie.
Why it decided that: depends entirely on your matcher, and this is the hop where half of all loops turn around. If your matcher is the negative-lookahead one every tutorial ships:
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};then /login is inside it, the guard runs, finds no session, and redirects to
/login. The loop is two rows long and both of them are 307. That is the
easiest version of this bug to spot and the fastest to fix.
Assume your matcher does exclude /login, because otherwise you would have
found it already. The page renders normally. The provider mounts, calls
getSession, reads localStorage, and finds the session id hop 1 could not
see.
Hop 3: the login page bounces you back
What the page saw: isAuthenticated: true, plus a redirect query
parameter pointing at /dashboard.
Why it decided that: because a login page that leaves a signed-in user staring at a sign-in form is a bug in its own right, so every login page grows this effect. Ours has it. The console's sign-in form reads the parameter, checks it is same-origin, and pushes:
const finalRedirect = new URL(redirectUrl, window.location.origin);
if (finalRedirect.origin === window.location.origin) {
router.push(finalRedirect.toString());
return;
}That is client/src/components/auth/userAuthForm.tsx, and there is nothing
wrong with it. On its own it is right. Paired with a middleware that looks
somewhere else for the same fact, it is the return leg of the loop.
Which navigation it uses decides which symptom you get. router.push and
router.replace are soft navigations: they fetch an RSC payload, middleware
307s that fetch, the router follows, and the address bar never changes. You get
a spinner and a repeating chain in the network tab. Assigning to
window.location.href is a hard navigation, the browser counts the hops
itself, and at twenty you get ERR_TOO_MANY_REDIRECTS. Same loop.
Different-looking bug report.
Hop 4: GET /dashboard, again
What middleware saw: the same request as hop 1. Same headers. Same absent
cookie. Nothing in hops 2 or 3 wrote one, because nothing in hops 2 or 3
carried a Set-Cookie header.
Why it decided that: it has no memory. Each invocation is a fresh function over one request. There is no "I already sent this person to /login once" state, which is precisely why the loop is infinite rather than self-limiting.
Go back to hop 1 and repeat until the browser stops you.
Key takeaway
The loop is not two components disagreeing about whether you are signed in. It is two components reading different storage and both reporting honestly. Find the hop where the answer changes, and you have found which one is looking in the wrong place.
The state middleware does not have
Client-side guards have three states: signed in, signed out, and not known yet.
The console's AuthProvider opens with the third one:
useEffect(() => {
if (isLoading) return;
// ...
if (!isLoggedIn && pathname?.startsWith('/dashboard')) {
const destination =
pathname + window.location.search + window.location.hash;
router.replace(`/login?redirect=${encodeURIComponent(destination)}`);
}
}, [user, isLoggedIn, isLoading, pathname, router]);if (isLoading) return; is the whole safety mechanism. While the session is
resolving, the guard does nothing at all. Our protected-routes doc lists four
patterns for gating a route and every one of them has this branch, whether it
is status !== 'loading' or the loadingComponent prop.
Middleware cannot have that branch. It has to answer on the request in front of it, and "not known yet" has to collapse into one of the other two. It collapses into "signed out". Then it redirects.
That is not a flaw to be engineered around, it is the shape of the thing. Which leads to the three rules worth actually writing down.
Rule one: the matcher is an allowlist, not a filter
Matchers match the pathname. Not the query string, not the hash. A negative lookahead over the whole site is a filter you have to keep correct forever, and every route you add is a route you have to remember to exclude.
Invert it:
export const config = {
matcher: ['/dashboard/:path*'],
};One entry is enough: :path* means zero or more segments, so the pattern
covers /dashboard itself as well as everything under it, and it does not
catch /dashboardx. An allowlist cannot catch /login, because /login is
not under /dashboard.
It cannot catch your callback either. The bug is structurally unavailable.
One trap if you build the matcher from a route constant. Our CONSOLE_ROUTES
holds entries like '/dashboard/admin/auth?tab=config' because the console
navigates straight to a tab, and a matcher carrying a ?tab= on the end of it
is not a pattern at all: path-to-regexp reads the ? as a modifier and throws
instead of compiling. Those are navigation targets, not matcher patterns.
Match the path, then read the tab inside the function with
request.nextUrl.searchParams.get('tab').
Rule two: the cookie is not readable on the hop that sets it
Here is the callback route, the one that turns an auth code into a session:
// app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const SESSION_COOKIE = 'bb-session';
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('code');
if (!code) return NextResponse.redirect(new URL('/login', request.url));
const res = await fetch('https://api.yourapp.com/api/auth/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code }),
});
const { sessionId } = await res.json();
const response = NextResponse.redirect(new URL('/dashboard', request.url));
response.cookies.set(SESSION_COOKIE, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 30, // the 30-day session default
});
return response;
}Two things about that last block.
response.cookies.set writes a Set-Cookie header. It does not put anything
into request.cookies, not in this function and not in a middleware that does
the same thing. If you set a cookie on a redirect response and then read it
back in the same invocation, you read nothing. The browser stores it and sends
it on the next request, which is the earliest hop any edge code can see it.
And if /auth/callback is inside your matcher, none of this runs. Middleware
executes before the route handler, sees a request with no session cookie,
returns its 307, and the code is never exchanged. The user is now in a loop
whose cause is a route that was never allowed to do its job. That is the
nastiest version of this bug, because the broken thing and the visible thing
are two different files.
Rule three: check for presence, do not verify
The temptation is obvious. You have the session id right there, so why not confirm it is real before letting the request through?
Because of what a session id is. In our token catalogue the app session is an
opaque handle against a Redis record with a 30-day TTL - there is nothing in
the string to check locally, so verifying means a network call from the edge on
every request, including every RSC payload and every <Link> prefetch. Then
somebody writes the sensible-looking thing:
try {
await verifySession(id);
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}and now a timeout, a cold start or a deploy is indistinguishable from a user who never signed in. Every blip becomes the loop.
Short-lived artifacts make it worse. SESSION_DEFAULTS.tokenExpiry is
2-minute, and that is not the session, it is the handoff: the single-use,
PKCE-bound code that carries a login across the redirect from the auth UI to
the app. A middleware that tries to verify whatever token-shaped string it
finds mid-flow will, on a refresh, be handed one that was already consumed.
Verification fails. It redirects to /login. Login starts again.
Presence is a routing decision and belongs at the edge. Validity is an
authorization decision and belongs on the server that serves the data, where
the answer to a bad session is 401 and not another 307. The same split we
argue for quotas in
enforcing quotas server-side:
the cheap gate up front, the real one where it cannot be skipped.
The whole thing
// middleware.ts, at the project root, next to app/
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const SESSION_COOKIE = 'bb-session';
export function middleware(request: NextRequest) {
// Presence only. Whether the session is real is the API's answer to give.
if (request.cookies.has(SESSION_COOKIE)) {
return NextResponse.next();
}
const login = request.nextUrl.clone();
login.pathname = '/login';
login.search = '';
login.searchParams.set(
'redirect',
request.nextUrl.pathname + request.nextUrl.search
);
return NextResponse.redirect(login);
}
export const config = {
// An allowlist. /login and /auth/callback are not under /dashboard, so this
// matcher cannot redirect to a path it also guards. `:path*` is zero or more
// segments, so /dashboard itself is covered.
matcher: ['/dashboard/:path*'],
};I have not run this in this repo, and I want to be straight about why: there is
no middleware.ts in it. Not in the console, not in the auth app, not on the
marketing site. Seven apps, zero middleware files. The console guards
/dashboard in a client provider with an isLoading branch, because it holds
its session where only the client can read it.
That is a choice, not a recommendation. Middleware is genuinely good at a cheap, cookie-shaped redirect for a signed-out visitor, and it saves you rendering a dashboard shell for someone who will never see it. It is bad at anything it has to ask another service about. If you take the code above, you are taking the first job and leaving the second one alone.
Where your own loop turns around
Walk your four hops and find the one where the answer changes.
The cookie is in Local Storage, not Cookies. Open devtools, Application,
and look at which panel the session id is in. If it is in Local Storage, hop 1
was always going to redirect. Either move it to an HttpOnly cookie in the
callback route, or delete the middleware and guard in the layout.
Both rows are 307. Your matcher covers its own redirect target. Look at
config.matcher before you look at anything else.
The callback shows a 307 and no 200. The handler never ran, so the session was never created. Take the callback path out of the matcher.
The cookie exists but arrives on the second request. Check sameSite. With
strict, a cookie is not sent on a navigation that started on another site,
and the hop back from a separate sign-in domain is exactly that. This gives you
one bounce, not an infinite loop - unless your login page bounces back, and
then it is a loop after all. lax is the right default here.
The loop only happens in production. Check the cookie domain. A cookie
set on app.example.com is not sent to example.com, and a middleware running
on the apex sees nothing. Localhost hides this completely, because everything
is one host.
There are 307s before anyone clicks. In a production build a <Link>
prefetches as soon as it scrolls into view, and that RSC request goes through
middleware and obeys the redirect. A redirected prefetch is one 307, not the
loop, but it tells you the guard is already answering "signed out" for that
route before any navigation has happened.
If you are wiring the guard for a workspace-scoped route rather than a flat
/dashboard, the routing side of that is in
multi-tenant workspaces, and
the case for not hand-rolling the session layer at all is the arithmetic in
the cost of building auth yourself.
Install
npm i @buildbase/sdk