In a Next.js App Router app, middleware sends a signed-out visitor from /dashboard/settings to /login. How do you get that visitor back to /dashboard/settings after they sign in, and what must you check about the stored destination before you redirect to it?
answer
- don't lose where they were going
- carry the destination on the login URL
- pathname plus search from request.nextUrl
- the returned value is untrusted input
- relative path only; reject // and /\
basics
~20 sStore the original path in a query parameter on the login URL: read it from request.nextUrl in middleware, redirect to /login?callbackUrl=/dashboard/settings, then after sign-in redirect back only if that value is a relative, same-origin path.
solid answer
~50 sIn `middleware.ts` I build the login URL with `new URL('/login', request.url)` and attach the intended destination as a query parameter, typically `callbackUrl`, taken from `request.nextUrl.pathname` plus `request.nextUrl.search` so filters survive. I return `NextResponse.redirect(loginUrl)`. The login page reads that parameter (in Next.js 15 and later, a page's `searchParams` is a Promise, so it is awaited), and the sign-in Server Action calls `redirect(target)` on success. The critical part is that the parameter is attacker-controlled input: I only redirect to a value that starts with a single `/` and is not `//` or `/\`, otherwise I fall back to a default like `/`. Without that check anyone can mail a link to your real login page that bounces the user to a lookalike host after a genuine sign-in. I also keep `/login` itself out of the gate so the redirect cannot loop.
go deeper
Know that the destination is carried as a query parameter on the login URL and read back after sign-in. Be able to name where it comes from: request.nextUrl.pathname plus the search string.
Explain the mechanics: NextResponse.redirect needs an absolute URL built from request.url, searchParams.set handles encoding, and the login route must be exempt or the redirect loops.
Show that you treat the returned destination as untrusted input. Describe the open-redirect scenario concretely and state the accept-rule you enforce, including protocol-relative and backslash forms.
Own the rule centrally rather than per feature: one helper that produces login URLs and one that validates return targets, so a new sign-in surface cannot reintroduce the redirect hole a year later.
## What the pattern has to accomplish An auth gate has two jobs that pull in different directions. It must stop a signed-out visitor from landing on a page that will be empty or broken for them, and it must not punish them for having a bookmark. If every gated request lands on `/login` and sign-in then drops the user on `/`, the user has to navigate back to whatever they were doing — and deep links from email, Slack or a bookmark bar stop working in practice. The fix is to carry the intended destination through the login round trip. Next.js middleware is a natural place to do it because it runs before the route renders and already has the URL in hand. ## Building the login URL ```ts import { NextResponse, type NextRequest } from 'next/server' export function middleware(request: NextRequest) { if (request.cookies.has('session')) return NextResponse.next() const loginUrl = new URL('/login', request.url) loginUrl.searchParams.set( 'callbackUrl', request.nextUrl.pathname + request.nextUrl.search, ) return NextResponse.redirect(loginUrl) } ``` Three details matter. `NextResponse.redirect` needs an absolute URL, which is why the target is built with `new URL('/login', request.url)` rather than passed as a bare string. `request.nextUrl` is the parsed URL of the incoming request, so `pathname` and `search` give you the destination exactly as the user asked for it — including `?tab=billing`, which users notice when it disappears. And `searchParams.set` percent-encodes the value for you; hand-concatenating `'?callbackUrl=' + path` breaks the moment the path contains a query string of its own. The name `callbackUrl` is a convention, not a Next.js API — `next` or `returnTo` work identically. What matters is that both ends agree. ## Coming back afterwards The login page reads the parameter. In Next.js 15 and 16, a page's `searchParams` prop is a Promise and must be awaited before you index into it. The value is then handed to the sign-in flow — usually a hidden form field — and on success the Server Action calls `redirect(target)` from `next/navigation`. ## Why the destination must be validated This is the part interviewers are actually probing. `callbackUrl` arrives from the URL bar, so it is untrusted input, and feeding it straight into a redirect is an open redirect. The attack does not need a bug in your login page. An attacker sends `https://app.example.com/login?callbackUrl=https://app-example.evil/session-expired`. The victim sees your genuine domain, your genuine TLS certificate, and signs in for real. Your app then navigates them to the attacker's lookalike, which asks them to "confirm" their password or approve an OAuth consent. Your domain lent its credibility to the hop. The defensive rule is to accept only a same-origin relative path: ```ts export function safeCallback(raw: string | null): string { if (!raw) return '/' if (!raw.startsWith('/')) return '/' // absolute URL, or a scheme if (raw.startsWith('//') || raw.startsWith('/\\')) return '/' return raw } ``` The two special cases are what people miss. `//evil.test/x` is a protocol-relative URL: it starts with a slash but resolves to a different host. And several URL parsers normalise a backslash to a slash, so `/\evil.test` can end up meaning the same thing. If you prefer parsing over string checks, do `new URL(raw, origin)` and require `url.origin === origin` — but then remember to redirect using the reconstructed path, not the raw string. ## Two operational details Exclude `/login` from the gate, either in the matcher or in the condition. Otherwise a signed-out request to `/login` is redirected to `/login`, which is redirected to `/login`, and the browser eventually gives up with a redirect-loop error. Also decide what happens for non-page requests. Bouncing a `fetch` to a Route Handler into an HTML login page produces a confusing parse error on the client; those surfaces usually want a 401-style JSON response instead of a redirect. ## What this pattern is not The cookie check that triggers the redirect is a routing convenience, not authorization. It proves a cookie was sent, nothing more. The login page and every page behind the gate must still verify the session where they read data — the redirect only saves the user a wasted render.
- Why do you keep the login route itself out of the middleware gate?Because the gate's condition is "no session cookie", and `/login` is exactly where a visitor with no session cookie is going. Without an exemption, the request to `/login` is redirected to `/login` again and the browser stops with a redirect-loop error. Either exclude it in the `config.matcher` or return `NextResponse.next()` early when the pathname is a public one.
- The user was on a filtered list view when they were bounced to login. What exactly do you store?`request.nextUrl.pathname + request.nextUrl.search`, set through `searchParams.set`, so the query string travels with the path and gets percent-encoded once. Storing only the pathname silently drops the user's filters, sort order and pagination, which reads as a bug. Hash fragments cannot travel this way at all — the browser never sends them to the server.
- Would you keep the destination in a cookie instead of a query parameter?It works and hides the destination from the URL bar, but it is per-browser rather than per-tab, so two tabs racing through login can send a user to the wrong place. You must still validate the value on the way out — a cookie set by an earlier request is no more trustworthy — and clear it after use so a stale destination does not resurface days later.
saying these in an interview costs you the question
- Just redirect to whatever callbackUrl the query string contains.
- An open redirect is harmless because no cookies are leaked.
- Percent-encoding the destination makes the redirect safe.
- A leading slash always means the URL stays on our site.
- Middleware already gated it, so the login page can trust the request.