Guides
Build guide

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.

Dharmendra Jagodana9 min read

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').

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
authentication
nextjs
middleware

Frequently Asked Questions

Why does middleware redirect me to /login when the session cookie is right there in devtools?

Because it is probably not a cookie. If your app persists the session with localStorage.setItem("sessionId", ...), devtools shows it under Local Storage, not Cookies, and middleware at the edge can read neither localStorage nor anything else the browser did not put on the request. Move the session id into an HttpOnly cookie, or stop guarding the route in middleware.

Does middleware run on client-side navigations in the App Router?

Yes. A router.push() or a <Link> prefetch issues an RSC request, and that request goes through middleware and obeys its redirect. That is why the loop can spin without the address bar ever changing, and why a nav link that has only scrolled into view can already show a 307 in the network tab before anyone clicks.

Should I verify the session in middleware or just check that it is present?

Check presence. A BuildBase session id is an opaque handle backed by a 30-day record, so verifying it at the edge means a network call on every request and every prefetch. Verify on the server that actually serves the data, where a failure returns 401 instead of another 307.

Why does my /auth/callback route never run?

Middleware runs before the route handler. If the matcher covers /auth/callback, the guard sees a request with no session cookie, returns a 307 to /login, and the handler that would have exchanged the code and set the cookie is never reached. Exclude the callback path from the matcher.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime