A user opens GET /dashboard/reports without signing in. The application should send them to /login before rendering the dashboard, then return them to the original page after a successful login. That sounds like one redirect, but it creates several independent questions:
- Does the request-time guard recognize a valid session, or merely the presence of a cookie?
- Which URLs run through the guard, including nested routes and API requests?
- Can the
nextparameter send the user to an external website? - If someone bypasses the redirect, does the data operation still enforce access control?
Middleware or Proxy can improve the navigation flow. It is not the security boundary for private data. This guide builds the redirect, tests its return-path rules, and identifies exactly what still needs authorization.
The examples target the Next.js 15.5 App Router, including the 15.5.28 version used by this portfolio. Next.js 16 renamed Middleware to Proxy and changed its default runtime. The naming matters when copying code between versions.
First, check which Next.js version you are using
| Version | File and export | Default runtime |
|---|---|---|
| Next.js 15.5 | middleware.ts and middleware | Edge; optional stable Node.js runtime |
| Next.js 16+ | proxy.ts and proxy | Node.js |
Next.js 15.5 can opt into Node.js Middleware with config.runtime: 'nodejs'. In Next.js 16+, the Proxy file uses Node.js and does not accept a runtime config option. See the versioned Middleware reference and the current Proxy reference.
That also changes what the old phrase "authentication at the Edge" means. Edge is a runtime and deployment decision, not proof that a check executes in a particular city or reduces latency on every platform. A fast redirect might avoid rendering unnecessary UI, but a remote database check can make the same request slower. Measure the deployed path before claiming an improvement.
For fixed path migrations, prefer redirects in next.config. Request-dependent checks such as a session-aware login redirect are a reasonable use for Middleware or Proxy.
A redirect tells the browser to request another URL and updates the address bar. NextResponse.redirect() defaults to a temporary 307 response, appropriate for sending an unauthenticated page visitor to login without permanently caching that destination. A rewrite serves content from another route while leaving the original URL visible; it does not verify a session or replace access control. Use NextResponse.rewrite() for routing variants, not to disguise a protected page.
Define a narrow navigation contract
Assume the application has public pages /, /login, and /register, plus protected pages under /dashboard and /settings. Its API can also expose private data, but those endpoints will authenticate and authorize themselves.
| Incoming request | Session | Intended behavior |
|---|---|---|
GET /dashboard | Missing or invalid | Redirect to /login?next=%2Fdashboard |
GET /dashboard/reports?page=2 | Missing or invalid | Redirect, retaining the local path and query |
GET /dashboard | Valid | Continue to the route |
GET /login | Any | Do not invoke this guard |
GET /dashboard-admin | Missing | Do not accidentally match a partial prefix |
GET /api/reports | Any | Not covered by the page matcher; API enforces its own policy |
POST /dashboard (Server Action request) | Missing or invalid | Return 401, not a method-preserving redirect to /login |
The last row is intentional. A redirecting page guard should not silently turn JSON API failures into HTML login pages. The API must return an appropriate unauthorized or forbidden response after checking the actual operation.
Implement a verified, short-lived session check
This example assumes your login system issues a signed HS256 JWT in a session cookie, with a subject (sub), issuer, audience, issued-at time (iat), and expiration. It uses jose because its verification APIs support Web Crypto. If you use Auth.js, an opaque database session, or another provider, use that provider's supported verification integration instead. Do not substitute a cookie-presence check.
Install jose v6.2.12 in the example application. This pins a specific major-version implementation for the snippet, rather than silently changing APIs with an unrestricted install; update the dependency and lockfile as maintained fixes are released. Earlier jose 6.0.3 had an Edge-runtime regression fixed in 6.0.4.
npm install jose@6.2.12
Generate a high-entropy secret outside source control (for example, openssl rand -base64 32), and store it as the server-only SESSION_SECRET_BASE64 environment variable. Do not prefix it with NEXT_PUBLIC_. The code below verifies an existing token; it does not implement credential verification, token issuance, refresh, revocation, or logout.
// src/lib/verify-session.ts (Next.js 15.5 example)
import { jwtVerify } from 'jose'
export async function hasValidSession(
token: string | undefined
): Promise<boolean> {
if (!token) return false
const secret = process.env.SESSION_SECRET_BASE64
if (!secret) throw new Error('Missing SESSION_SECRET_BASE64')
const key = Uint8Array.from(atob(secret), c => c.charCodeAt(0))
if (key.length < 32) throw new Error('Session key is too short')
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'],
issuer: 'your-app',
audience: 'web',
maxTokenAge: '15m',
requiredClaims: ['exp'],
})
return typeof payload.sub === 'string' && payload.sub.length > 0
} catch {
return false
}
}
The issuer must create tokens using the same key, algorithm, issuer, and audience, set iat and a bounded exp, and transmit the cookie with appropriate HttpOnly, Secure, SameSite, and Path attributes. maxTokenAge requires iat, while requiredClaims: ['exp'] ensures a token without an expiration is rejected. See the jose JWT verification options. A short lifetime limits the period during which a stolen or revoked token might still verify locally; it does not provide instant revocation. A signature alone does not establish current membership or permission.
The sample is illustrative, not a deployed authentication system. Production applications should prefer a maintained auth library where possible, and must plan for rotation, invalidation, and account state changes.
Redirect only the routes you actually want to guard
Redirect only page navigations. A 307 preserves the original HTTP method, so redirecting an unauthenticated POST /dashboard could send that POST to /login. That is not a valid login navigation and can disrupt a Server Action request. A 303 would switch to GET, but this example deliberately refuses unauthenticated non-navigation requests with 401. The mutation itself must still authenticate and authorize the caller.
Keep the method policy in a small helper that uses the standard Web Request and Response APIs supported by Next.js Middleware:
// src/lib/unauthenticated-response.ts
export function unauthenticatedResponse(request: Request): Response {
const noStore = { 'Cache-Control': 'no-store' }
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response(null, { status: 401, headers: noStore })
}
const incoming = new URL(request.url)
const login = new URL('/login', incoming)
login.searchParams.set('next', incoming.pathname + incoming.search)
return new Response(null, {
status: 307,
headers: { ...noStore, Location: login.toString() },
})
}
The Location header is absolute and built from the incoming local path. The temporary redirect and the unauthorized response are marked Cache-Control: no-store, since they are session-dependent. This helper does not itself authenticate a request; it handles an already-determined unauthenticated case.
Put the following next to your src/app directory, in src/middleware.ts for Next.js 15.5:
// src/middleware.ts (Next.js 15.5)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { hasValidSession } from './lib/verify-session'
import { unauthenticatedResponse } from './lib/unauthenticated-response'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (await hasValidSession(token)) {
return NextResponse.next()
}
return unauthenticatedResponse(request)
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
The two static matcher patterns cover the root and descendants of those route groups. They do not include /login, avoiding a common redirect loop, and do not rely on an overly broad negative-lookahead expression. The generated next value is assembled from the incoming request's local path and query, not from a user-supplied destination. Unauthenticated GET/HEAD requests receive a 307 redirect; unauthenticated POST and other methods receive 401 without a Location header.
A request carrying a tampered JWT still gets redirected. A verified JWT lets the request continue, but does not authorize the dashboard's data reads or mutations. It also does not protect an endpoint that falls outside the matcher.
For Next.js 16+, change the file to src/proxy.ts and rename the exported function to proxy; keep the same request handling and matcher. Do not keep an Edge-specific runtime assumption or add config.runtime to Proxy. Follow the official migration notes when upgrading a real application.
The return URL is a second trust boundary
Suppose the login form accepts ?next=/dashboard/reports. After successful authentication, it is tempting to call redirect(next). Do not do that with arbitrary input: a visitor could send a crafted login link containing an external destination.
Whitelist application routes, reject protocol-relative and non-path values, and normalize with the URL parser before using the destination.
// src/lib/safe-return-path.ts
const fallback = '/dashboard'
const base = 'https://app.invalid'
const allowedRoots = ['/dashboard', '/settings']
export function safeReturnPath(candidate: string | null): string {
if (
!candidate ||
candidate.length > 2048 ||
!candidate.startsWith('/') ||
candidate.startsWith('//') ||
candidate.includes('\\')
) return fallback
let target: URL
try {
target = new URL(candidate, base)
} catch {
return fallback
}
const allowed = allowedRoots.some(
root =>
target.pathname === root ||
target.pathname.startsWith(root + '/')
)
return target.origin === base && allowed
? target.pathname + target.search
: fallback
}
https://app.invalid is only a fixed parsing base, not a deployed endpoint. The helper returns a relative local destination. Call it in the successful server-side login flow, after verifying credentials and establishing the session, before passing its result to redirect(). Never treat the next parameter as proof of identity.
The return-path helper was exercised in a standalone Node.js fixture with 12 passing cases, including a nested query, //evil.example, javascript:, backslashes, disallowed routes, and a partial-prefix trap. A second fixture exercises the exact Web Request/Response method helper above in eight cases, checking redirect and 401 statuses, Location, and Cache-Control. These are 20 passing standalone Node tests, not a Next.js login flow, matcher test, or JWT integration.
The guard must not decide who can read a record
Think of the redirect as an optimistic decision: a signed-in browser probably belongs on the dashboard. The database operation is the authoritative decision: this specific user may read or change this specific record.
A dashboard Server Component, Server Action, or Route Handler must independently resolve the caller from a trusted server session and enforce current permissions close to its data source. A direct POST, changed object ID, expired membership, or API call can bypass UI assumptions. Next.js explicitly describes this separation in its authentication guide and data security guidance.
For a concrete cross-user mutation example, see why authentication is not authorization in a Next.js Server Action. For choosing between mutation entry points, see Server Actions vs. API Routes. Both entry points need permission checks.
Other practical boundaries matter:
- Revocation: A cryptographically valid, unexpired JWT may belong to a deleted account or revoked session. Sensitive operations need current account and permission state.
- Request methods: Middleware can also run on matched POST requests, including Server Function transport. A
307preserves POST; this example returns401instead of redirecting an unauthenticated mutation. A redirect or early rejection is not a substitute for verifying permissions at the mutation itself. See MDN on 303 and Next.js redirect status behavior. - Prefetch and caching: Client navigation and prefetch can trigger guard work. Do not use an unauthenticated cache key for user-specific responses; verify effective behavior in the actual hosting environment.
- APIs: Return
401for missing authentication and403where an authenticated actor lacks permission, if doing so does not reveal private resource existence. A page-oriented307redirect is not an API authorization contract. - Error handling: An absent or invalid token should not throw; a missing signing secret should fail loudly as a deployment misconfiguration rather than silently allow access.
Test the routing boundary, then test authorization separately
The following are integration checks to run in the target Next.js app, not tests claimed to have passed here:
- Visit
GET /dashboard/reports?page=2without a cookie and inspect the307response'sLocationheader for the encoded local return URL. - Repeat with a malformed, incorrectly signed, and expired cookie. All should be treated as unauthenticated.
- Use a valid token. Verify the route continues, then separately verify that the caller cannot read another user's data.
- Visit
/login,/dashboard-admin, an asset URL, and a private API endpoint. Confirm matcher scope and API-level responses. - Submit
next=//evil.exampleornext=https://evil.exampleto the login flow and confirm the destination falls back locally. - Submit a direct unauthenticated
POST /dashboardas a Server Action transport request. Verify401with noLocationheader, not a POST redirect to/login. For an authenticated request, assert the Server Action still enforces record-level permissions. - Test logout, revocation, role changes, prefetch, and error handling.
Next.js 15.1+ provides the experimental unstable_doesMiddlewareMatch test helper; newer versions expose unstable_doesProxyMatch. Use the appropriate version's reference to assert matcher behavior before relying on it.
If the outcome requires a static redirect that does not depend on request state, the smaller next.config redirect configuration is usually the better option. If the outcome requires object-level permission, put that decision in the server-side data operation. Middleware or Proxy is the narrow layer in between: it can route a request to the right page without pretending to secure every endpoint behind it.
