Deepen PracticeOrdered learning track

Edge Middleware Auth

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 075

Edge Middleware Auth for advanced React and Next.js engineers: coarse authentication, redirect gating, session projection, edge runtime limits, cache safety, route matching, and why edge middleware is not a complete authorization boundary.

9 min read1629 words
PrevNext
Lesson 75130 lesson track72–107 Deepen Practice
#react#nextjs#edge#middleware+12 more

Part 075 — Edge Middleware Auth

Edge middleware is attractive because it runs before the route is rendered.

That makes it useful for:

redirecting anonymous users away from authenticated areas
blocking obvious unauthenticated requests early
attaching request context headers
normalizing tenant routing
performing coarse route segmentation
preventing protected layout flash

But edge middleware is not a magical security boundary.

The dangerous belief is:

If middleware protects the route, the route is secure.

The correct model is:

Edge middleware can be a coarse authentication gate.
Server loaders, route handlers, server actions, and APIs still enforce authorization.

In Next.js terminology, the modern file convention is proxy.ts/proxy.js for code that executes before routes are rendered. Older materials often call this middleware.ts. The conceptual pattern is the same: a pre-route interception layer that can redirect, rewrite, or shape requests.


1. Why edge auth exists

React applications have a timing problem.

A pure client-side guard runs after:

HTML loaded
JS downloaded
React booted
component tree rendered enough to decide auth

That is late.

For authenticated layouts, late auth causes:

protected shell flash
wrong navigation menu flash
loading skeleton for a user who should be redirected
unnecessary data requests
confusing back-button behavior
SEO/indexing mistakes for protected routes

Edge middleware moves the first decision earlier:

Notice the two-level design:

Edge middleware checks whether the request is plausibly authenticated.
The data/API/resource layer checks whether the user may access the resource.

2. What edge middleware is good at

Use edge auth for coarse decisions.

Good use cases:

anonymous vs authenticated route groups
public vs app shell separation
login page redirect if already signed in
organization slug normalization
locale/region/tenant routing
basic bot/rate-limit hints
correlation ID injection
cache-control safety headers for protected route groups
redirect loop prevention

Example route groups:

/                  public marketing
/login             anonymous-only auth route
/app               authenticated shell
/app/:tenant       authenticated tenant shell
/app/:tenant/admin authenticated + admin navigation shell
/api/bff/*         authenticated BFF endpoints

Edge middleware can make these routes behave consistently.


3. What edge middleware is bad at

Do not use edge middleware as your only authorization layer.

Bad use cases:

object-level authorization
field-level authorization
workflow transition authorization
separation-of-duties checks
permission checks that require fresh database state
large policy evaluation
complex organization graph traversal
billing entitlement calculation
row-level filtering
file/object access enforcement
mutation authorization

Why?

Because edge middleware is usually far away from the full domain context.

It often has limited access to:

fresh resource state
transactional data
database joins
policy trace
permission graph consistency
backend secrets
Node-only libraries
large dependency bundles

Even if it can technically call a policy service, that does not always mean it should.

A bad edge middleware becomes a high-latency, high-blast-radius global dependency.


4. The core invariant

Middleware can decide where the request should go.
Middleware must not be the only place that decides what the user can do.

A secure app keeps these boundaries separate:

LayerResponsibilitySecurity authority?
Edge middleware/proxyCoarse gate, redirect, request shapingPartial
Server Component / loaderSession projection, safe data preparationPartial
Route Handler / BFFAPI boundary, CSRF, mutation endpointYes for its endpoint
Domain serviceResource authorization and business invariantYes
Policy engineDecision evaluationYes
React componentExposure control and UXNo

Frontend engineers often want middleware to solve everything because it is central.

But central is not the same as authoritative.


5. Minimal edge auth shape

The minimum useful middleware does three things:

1. classify route
2. inspect session signal
3. redirect or continue

Pseudo-code:

// proxy.ts or middleware.ts depending on framework version/convention
import { NextRequest, NextResponse } from "next/server";

const AUTH_COOKIE = "__Host-app_session";

const PUBLIC_PATHS = ["/", "/pricing", "/docs"];
const AUTH_PATHS = ["/login", "/signup", "/callback"];
const APP_PREFIX = "/app";

function isPublicPath(pathname: string) {
  return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
}

function isAuthPath(pathname: string) {
  return AUTH_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
}

function safeInternalReturnTo(req: NextRequest) {
  return req.nextUrl.pathname + req.nextUrl.search;
}

export function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const session = req.cookies.get(AUTH_COOKIE)?.value;

  if (isPublicPath(pathname)) {
    return NextResponse.next();
  }

  if (isAuthPath(pathname)) {
    if (session) {
      return NextResponse.redirect(new URL("/app", req.url));
    }
    return NextResponse.next();
  }

  if (pathname.startsWith(APP_PREFIX) && !session) {
    const login = new URL("/login", req.url);
    login.searchParams.set("returnTo", safeInternalReturnTo(req));
    return NextResponse.redirect(login);
  }

  return NextResponse.next();
}

This does not prove the session is valid.

It only proves that the request contains a session-looking signal.

That may be enough to prevent anonymous layout rendering, but it is not enough to authorize data.


6. Should middleware verify the session?

There are three common levels.

Check that a session cookie exists.
Do not verify it.
Redirect anonymous users.

Pros:

fast
cheap
minimal edge dependencies
works with opaque server sessions

Cons:

stale/revoked cookies may pass
corrupt cookies may pass
authenticated shell may render before server rejects

Best for:

coarse route redirect only

Level 2 — Stateless verification

Verify signed/encrypted session token or JWT at edge.

Pros:

stronger coarse gate
can reject expired/tampered tokens early
less load on origin

Cons:

revocation is hard
claims may be stale
key rotation must work at edge
JWT size may hurt every request
complex crypto/dependency constraints

Best for:

short-lived signed session envelopes
low-risk route gating
systems with reliable key distribution

Level 3 — Remote introspection/session lookup

Call session service or policy service from edge.

Pros:

fresh revocation
centralized session truth
can support device/session invalidation

Cons:

latency on every protected request
edge-to-origin coupling
outage amplification
harder caching semantics
risk of redirect storm during partial outage

Best for:

high-security environments with explicit SLOs
small set of protected entry points
risk-adaptive checks

A practical default:

Use middleware for route classification + lightweight session signal.
Use server/BFF/domain services for fresh authorization.

7. Edge runtime limitations matter

Edge runtimes are not equivalent to Node.js runtimes.

Design as if edge has constraints around:

available APIs
crypto libraries
TCP/database drivers
filesystem access
large packages
cold starts
regional consistency
secret access patterns
request body handling

This affects auth design.

For example:

Do not put a heavy ORM in edge middleware.
Do not require database joins in edge middleware.
Do not import a full admin SDK if only cookie presence is needed.
Do not make every navigation depend on a slow policy endpoint.

The edge layer should stay small.

A healthy middleware file often looks boring.

Boring is good.


8. Route matching strategy

A common production bug is middleware running on too many paths.

You usually do not want auth middleware on:

static assets
images
favicon
robots.txt
public health checks
public OAuth callback assets
internal framework chunks

Example matcher idea:

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
  ],
};

But route matching must be reviewed against your actual framework/version.

The stronger design is explicit classification:

type RouteClass =
  | "public"
  | "anonymous_only"
  | "authenticated"
  | "tenant_authenticated"
  | "admin_shell"
  | "api_bff"
  | "asset";

Then every branch is intentional.

function classify(pathname: string): RouteClass {
  if (pathname.startsWith("/_next/")) return "asset";
  if (pathname === "/login") return "anonymous_only";
  if (pathname.startsWith("/app/admin")) return "admin_shell";
  if (pathname.startsWith("/app")) return "authenticated";
  if (pathname.startsWith("/api/bff")) return "api_bff";
  return "public";
}

This makes route security review easier.


9. Redirect loop prevention

Middleware redirect loops are easy to create.

Classic loop:

/app -> middleware sees no valid session -> /login
/login -> middleware sees no valid session -> /login

Another loop:

/login -> session cookie exists but expired -> /app
/app -> origin rejects session -> /login
/login -> cookie still exists -> /app

The mitigation is not one trick.

Use layered loop defense:

separate anonymous-only routes from authenticated routes
ignore callback routes in normal auth guard
validate returnTo as internal path
clear bad session cookies when server detects invalid session
include loop counters only as diagnostic fallback
make /logout and /session-expired safe terminal states

Example:

function redirectToLogin(req: NextRequest, reason: string) {
  const login = new URL("/login", req.url);
  login.searchParams.set("returnTo", req.nextUrl.pathname + req.nextUrl.search);
  login.searchParams.set("reason", reason);
  return NextResponse.redirect(login);
}

Do not put raw error details into the redirect URL.

Keep reason values typed and public-safe:

missing_session
expired_session
step_up_required
tenant_required

10. Return URL safety

Never allow arbitrary absolute return URLs.

Bad:

const login = new URL("/login", req.url);
login.searchParams.set("returnTo", req.nextUrl.href); // may preserve attacker-controlled form

Better:

function buildReturnTo(req: NextRequest) {
  return req.nextUrl.pathname + req.nextUrl.search;
}

Then validate again on login completion:

export function normalizeReturnTo(input: string | null): string {
  if (!input) return "/app";

  try {
    // Force relative-only paths.
    if (!input.startsWith("/")) return "/app";
    if (input.startsWith("//")) return "/app";

    const url = new URL(input, "https://app.example.test");

    if (url.origin !== "https://app.example.test") return "/app";
    if (url.pathname.startsWith("/login")) return "/app";
    if (url.pathname.startsWith("/callback")) return "/app";

    return url.pathname + url.search;
  } catch {
    return "/app";
  }
}

Return URL handling belongs to the same threat model as OAuth callback hardening.

A redirect is an authority transfer.

Treat it like one.


11. Middleware and authorization claims

You may be tempted to put roles in a cookie/JWT and check them at edge.

Example:

if (pathname.startsWith("/admin") && session.role !== "admin") {
  return NextResponse.redirect(new URL("/app", req.url));
}

This can be acceptable only as an admin shell exposure gate.

It cannot be the final admin authorization check.

Why?

role claim may be stale
user may have been deprovisioned
admin permission may be resource-scoped
role may be tenant-specific
separation-of-duties may depend on the action
server mutation endpoint can be called directly

Safer wording:

Middleware may prevent non-admin-looking users from seeing the admin shell.
Admin APIs must still enforce admin authorization.

12. Tenant-aware middleware

Multi-tenant apps often encode tenant in path:

/app/acme/cases/123

Middleware can normalize tenant context.

It can check:

is tenant slug syntactically valid?
does request have a tenant selection cookie?
does URL tenant match selected tenant?
should user be sent to tenant picker?

But be careful.

Middleware usually should not be the only tenant membership check.

Bad:

Edge middleware says tenant slug is valid, therefore user can access tenant.

Correct:

Edge middleware selects/normalizes tenant context.
Server loader/API verifies tenant membership and resource access.

Example shape:

function extractTenant(pathname: string) {
  const match = pathname.match(/^\/app\/([^/]+)/);
  return match?.[1] ?? null;
}

export function proxy(req: NextRequest) {
  const tenantSlug = extractTenant(req.nextUrl.pathname);
  const res = NextResponse.next();

  if (tenantSlug) {
    // Request context hint only. Server must verify.
    res.headers.set("x-request-tenant", tenantSlug);
  }

  return res;
}

Do not trust client-controlled headers like x-request-tenant from the browser.

If you use internal request headers, overwrite them at the trusted edge/server boundary.


13. Request context headers

Middleware is useful for attaching context.

Examples:

x-request-id
x-route-class
x-auth-signal
x-tenant-slug
x-auth-redirect-reason

But headers are dangerous if misused.

Rules:

never trust incoming client-provided internal headers
strip/overwrite internal headers before forwarding
never put raw token values in diagnostic headers
never put full user profile in headers
never put permission matrix in headers

Example:

export function proxy(req: NextRequest) {
  const requestHeaders = new Headers(req.headers);

  requestHeaders.delete("x-user-id");
  requestHeaders.delete("x-tenant-id");
  requestHeaders.delete("x-permission-debug");

  requestHeaders.set("x-request-id", crypto.randomUUID());
  requestHeaders.set("x-route-class", classify(req.nextUrl.pathname));

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

Only downstream trusted server code should consume these.


14. Cache safety at edge

Auth and edge caching are a dangerous combination.

Protected responses should avoid being cached as public/shared responses.

For authenticated route groups, middleware can add defensive headers:

const res = NextResponse.next();
res.headers.set("Cache-Control", "no-store");
res.headers.set("Vary", "Cookie, Authorization");
return res;

But understand the limit:

Middleware-set headers may not cover every downstream response in every architecture.
Route handlers and BFF endpoints should set their own cache-control.

Do not rely on one global edge header to protect all sensitive responses.

Sensitive data endpoints should own their own cache policy.


15. Middleware with BFF architecture

In a BFF design, edge middleware can protect the app shell and BFF endpoints.

A good BFF auth split:

ConcernEdgeBFF/Server
Is route public or protected?YesMaybe
Does session cookie exist?YesYes
Is session valid/revoked?OptionalYes
Should access token refresh?NoYes
Can user read case 123?NoYes
Can user approve transition?NoYes
Should response be audited?MinimalYes

Edge should not become the token vault.

Token refresh is usually a server/BFF concern.


16. Middleware for anonymous-only routes

Login pages have their own edge logic.

If the user already has a valid or plausible session, sending them to login creates bad UX.

if (isAuthPath(pathname) && session) {
  const returnTo = req.nextUrl.searchParams.get("returnTo");
  const safe = normalizeReturnTo(returnTo);
  return NextResponse.redirect(new URL(safe, req.url));
}

But be careful with stale cookies.

If the session-looking cookie is invalid, this can cause loop:

/login -> /app -> server rejects -> /login -> /app

The server rejection path should clear invalid session cookies.

For example:

GET /app
server detects invalid session
response clears __Host-app_session
redirects /login?reason=expired_session

Middleware alone cannot cleanly solve stale session loops unless it can validate the session.


17. Middleware and OAuth callback routes

OAuth/OIDC callback routes need special treatment.

Do not apply normal authenticated route guard to callback.

Callback may arrive before the app session exists.

/callback?code=...&state=...  -> no app session yet

If middleware redirects callback to /login because no session exists, you break the flow.

Route classification should include:

public_auth_callback

But callback is not “public page” in a product sense.

It is a protocol endpoint.

It must still perform:

state validation
nonce validation
PKCE verifier lookup
authorization code exchange
session creation
safe returnTo normalization
URL cleanup

18. Middleware and CSRF

Middleware can help with CSRF, but it should not be the only CSRF layer.

Possible middleware checks:

block unsafe methods without Origin/Referer on same-origin sensitive routes
attach CSRF expectation headers
reject cross-site requests to BFF endpoints

But CSRF validation is usually better placed at the mutation endpoint.

Why?

endpoint knows whether operation is sensitive
endpoint can validate CSRF token against session
endpoint can return typed error
endpoint participates in audit

For cookie-based BFF endpoints:

POST /api/bff/cases/123/approve
must validate session
must validate CSRF/origin policy
must authorize action
must validate business transition
must audit

Middleware can be a coarse pre-filter.

It cannot replace mutation-level checks.


19. Error semantics

Middleware has fewer response options than full application routes.

Common middleware responses:

redirect /login
redirect /session-expired
redirect /select-tenant
rewrite /forbidden
return 401/403 for API-like paths
continue

Use path-aware semantics.

For browser page navigation:

missing session -> redirect /login
expired session -> redirect /session-expired or /login?reason=expired_session
forbidden shell -> redirect /app or render forbidden page

For API/BFF endpoints:

missing session -> 401 JSON/problem response
expired session -> 401 JSON/problem response
forbidden -> 403 JSON/problem response

Do not redirect API clients to HTML login pages.

That creates impossible client parsing failures.


20. A typed middleware policy

A stronger middleware does not scatter path checks.

It uses a small policy table.

type RoutePolicy = {
  pattern: RegExp;
  className: "public" | "anonymous" | "authenticated" | "api";
  cache: "public" | "private" | "no-store";
};

const policies: RoutePolicy[] = [
  { pattern: /^\/$/, className: "public", cache: "public" },
  { pattern: /^\/login$/, className: "anonymous", cache: "no-store" },
  { pattern: /^\/callback$/, className: "public", cache: "no-store" },
  { pattern: /^\/app(\/.*)?$/, className: "authenticated", cache: "no-store" },
  { pattern: /^\/api\/bff(\/.*)?$/, className: "api", cache: "no-store" },
];

function resolvePolicy(pathname: string): RoutePolicy {
  return policies.find((p) => p.pattern.test(pathname)) ?? {
    pattern: /.*/,
    className: "public",
    cache: "public",
  };
}

Then behavior becomes reviewable:

export function proxy(req: NextRequest) {
  const policy = resolvePolicy(req.nextUrl.pathname);
  const hasSession = Boolean(req.cookies.get("__Host-app_session")?.value);

  if (policy.className === "authenticated" && !hasSession) {
    return redirectToLogin(req, "missing_session");
  }

  if (policy.className === "api" && !hasSession) {
    return NextResponse.json(
      { type: "auth.missing_session", title: "Authentication required" },
      { status: 401, headers: { "Cache-Control": "no-store" } },
    );
  }

  const res = NextResponse.next();
  if (policy.cache === "no-store") {
    res.headers.set("Cache-Control", "no-store");
  }
  return res;
}

This is easier to test than ad-hoc if statements.


21. Testing edge auth

Test middleware like a routing/security component, not like a React component.

Minimum test matrix:

CaseExpected result
anonymous user visits /appredirect to /login?returnTo=/app
anonymous user visits /logincontinue
session-looking user visits /loginredirect to /app or safe returnTo
anonymous user visits /callbackcontinue
anonymous API request to /api/bff/me401 JSON/problem, not HTML redirect
static asset requestcontinue without auth redirect
malicious returnTo=https://evil.examplenormalized to safe internal path
protected path with stale cookieorigin clears cookie and redirects safely
tenant path mismatchroute to tenant picker or continue for server verification

Also test matchers.

A broken matcher can silently remove auth from a route group.


22. Observability

Log middleware decisions carefully.

Useful fields:

request_id
route_class
decision: continue | redirect_login | reject_401 | rewrite_forbidden
reason: missing_session | anonymous_only | invalid_return_to
pathname_template, not raw sensitive URL
has_session_signal: true/false
tenant_slug_present: true/false

Avoid logging:

raw cookie values
authorization headers
full query strings with auth callback code
PII-rich URLs
policy trace with sensitive resource metadata

Metrics:

middleware.redirect.login.count
middleware.reject.unauthenticated.count
middleware.redirect.loop.detected.count
middleware.route_class.unknown.count
middleware.latency.p95
middleware.session_signal.present.rate

If middleware is globally deployed, noisy failures become global incidents.

Make it observable.


23. Failure modes

FailureCauseMitigation
Redirect loopstale cookie + anonymous-only redirectclear invalid cookies server-side; safe terminal routes
Protected shell flashclient-only guardmiddleware coarse gate + server data guard
Admin API bypassadmin shell only protected at middlewareenforce server/API authorization
Static asset breakagematcher catches assetsexplicit matcher exclusions
OAuth callback brokencallback guarded as authenticated routeclassify callback as protocol route
Edge latency spikeremote introspection every requestavoid or cache carefully; move fresh check to origin
Stale role at edgerole claim in cookie/JWTedge as exposure gate only; server validates fresh permission
Cache leakprotected response cached publiclyno-store, Vary, route-owned cache policy
Header spoofingtrusting client x-user-idstrip/overwrite internal headers

24. Production checklist

Before using edge middleware for auth, answer these:

Which routes are public, anonymous-only, authenticated, API, callback, or asset?
Can a protected route be reached without middleware because of matcher gaps?
Does middleware redirect pages and return JSON for API paths?
Are return URLs internal-only and normalized?
Are OAuth callback routes excluded from normal auth redirect?
Does server-side code still validate session and authorization?
Are internal headers stripped/overwritten?
Are protected responses no-store?
Can stale session cookies be cleared by server rejection paths?
Are route decisions logged without leaking secrets?
Can the app survive edge/session service partial outage?

The goal is not to make middleware clever.

The goal is to make auth failure early, predictable, and boring.


25. Final mental model

Edge middleware is a doorperson.
It can say: this entrance is public, login-only, or app-only.

It is not the judge.
It does not know every object, workflow, approval rule, or policy exception.

The resource boundary remains the authority.

If you remember one thing:

Use edge middleware to reduce wrong navigation.
Use server/domain authorization to prevent wrong access.

References

  • Next.js Documentation — proxy.js file convention and pre-route execution model.
  • Next.js Documentation — Authentication guide for App Router concepts.
  • Next.js Documentation — cookies() API for reading request cookies and writing outgoing cookies in supported server contexts.
  • MDN — HTTP cookies and secure cookie attributes.
  • OWASP Authorization Cheat Sheet.
  • OWASP CSRF Prevention Cheat Sheet.
Lesson Recap

You just completed lesson 75 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.