Skip to main content

← Blog

One Dashboard, Two Proxy Boundaries

Why the Ax402 seller dashboard keeps cookie-backed control-plane requests and user-influenced gateway traffic behind separate Next.js proxy routes.

Published
July 4, 2026
Updated
Updated July 13, 2026
Author
Daniel Mark
Reading time
10 min read

Two Next.js route handlers can both forward HTTP requests while granting the browser very different capabilities.

In the Ax402 seller dashboard, control-plane requests travel to one server-configured backend and carry session state. Workbench requests can contain gateway URLs associated with an operator’s APIs. The first boundary must centralize authentication policy. The second must constrain outbound destinations and request behavior.

Neither route becomes safe merely because it runs on the server. The important architectural decision was to keep the capabilities separate so that each policy can be hardened and tested independently.

This article describes the proxy boundary split implemented in the seller application as of July 2026. Both routes are undergoing a planned hardening pass. I am documenting the architectural decision and the constraints enforced today, not presenting either handler as a complete security reference.

The scenario behind the split

The seller application lives in apps/seller inside the ax402-clients monorepo. It combines two kinds of network traffic that look similar from the interface but behave differently at the trust boundary.

Most authenticated dashboard requests go through /api/backend/*. The browser calls a same-origin Next.js route, and that route forwards the request to a fixed control-plane backend. Session state comes from a cookie rather than a bearer token persisted in browser storage.

The workbench has a different job. It lets operators inspect and exercise gateway endpoints associated with their APIs. Sending those requests directly from the browser would require every gateway to permit the dashboard origin through CORS. Instead, the dashboard submits a request envelope to /api/gateway/inspect or /api/gateway/proxy, and the server performs the outbound request.

Both flows use a server-side proxy, but they do not grant the browser the same power.

Two boundaries compared

Concern

Cookie session proxy

Gateway proxy

Route

/api/backend/*

/api/gateway/inspect and /api/gateway/proxy

Browser-controlled input

Backend path, query, method, body and request headers

Gateway URL, method, body and request-specific headers

Destination

One backend origin selected by server configuration

A hostname drawn from the seller’s configured API domains and platform configuration

Session behavior

Converts the session cookie into control-plane authorization

Uses the seller session to calculate permitted gateway hostnames

Primary concern

Centralizing session forwarding without persisting bearer credentials in browser storage

Constraining user-influenced outbound requests

Current constraints

Fixed upstream origin, cookie-based session forwarding and control-plane host pinning

Hostname check, manual redirects and bounded request duration

Hardening direction

Explicit header ownership, response shaping and CSRF policy

Stricter URL, method, address and outbound-header policy

That last row matters. The separation exists today even though the policies inside each boundary are still evolving.

The fixed-destination session boundary

The session route is a Next.js catch-all handler. It preserves the incoming path and query string but always builds the destination from one configured backend origin:

The session route is a Next.js catch-all handler. It preserves the incoming path and query string but always builds the destination from one configured backend origin:

1
async function proxy(req: NextRequest, pathSegments: string[]) {
2
const path = pathSegments.join("/");
3
const url = `${backendBaseUrl().replace(/\/$/, "")}/${path}${req.nextUrl.search}`;
4
5
const headers = sellerSessionHeaders(req);
6
7
const hasBody = req.method !== "GET" && req.method !== "HEAD";
8
const backendRes = await fetch(url, {
9
method: req.method,
10
headers,
11
body: hasBody ? req.body : undefined,
12
duplex: hasBody ? "half" : undefined,
13
} as RequestInit);
14
15
// Build the same-origin response.
16
}

The browser can choose the backend path, method and request data. It cannot replace the configured backend origin through the request URL.

That gives the route a narrower responsibility than a general outbound proxy. It knows which system it represents and can apply control-plane-specific rules before forwarding traffic.

The browser client targets the same-origin /api/backend route with credentials: "include". The route reads the session cookie and constructs the upstream request. Authentication paths set the cookie after a successful response, while logout removes it.

The cookie configuration is explicit:

1
export function setSessionCookie(
2
res: NextResponse,
3
req: NextRequest,
4
token: string,
5
): void {
6
res.cookies.set(TOKEN_COOKIE, token, {
7
httpOnly: true,
8
sameSite: "lax",
9
path: "/",
10
maxAge: MAX_AGE_SEC,
11
secure: cookieSecure(req),
12
});
13
}

No Domain attribute is configured, so the cookie remains host-only. Secure is enabled for HTTPS deployments, while SameSite=Lax narrows when browsers attach the cookie to cross-site requests.

The seven-day maxAge controls how long the browser retains the cookie. It does not prove that the backend token remains valid for seven days. The backend’s own expiry policy remains authoritative.

What this establishes today

The application no longer needs to persist its bearer credential in localStorage or sessionStorage. Session forwarding has one server-side location where authentication headers, cookie behavior and response shaping can be controlled.

That is useful, but it is not the same as saying that the browser has disappeared from the threat model.

HttpOnly prevents JavaScript from reading the cookie through document.cookie. It does not prevent injected JavaScript from issuing authenticated requests while the user’s session is active. It also does not replace authorization checks in the control-plane backend.

SameSite=Lax reduces exposure to common cross-site request patterns, but it is not a complete CSRF policy. Explicit origin validation and response handling still belong to the boundary.

This distinction also applies to routing guards. The seller application checks the current session before allowing navigation into areas such as the dashboard and onboarding. Those checks improve the user experience, but the backend remains responsible for deciding whether a request is authorized.

The session route should therefore be understood as a place to enforce authentication policy, not as proof that every authentication concern has already been solved.

The user-influenced gateway boundary

The workbench proxy accepts a request envelope containing a URL, method, headers and optional body. That is a broader input surface than the fixed-backend proxy.

Before sending the request, the server extracts the hostname and compares it with a set assembled for the current seller:

1
const url = input.url?.trim();
2
const method = (input.method?.trim() || "GET").toUpperCase();
3
4
if (!url) {
5
return { ok: false, status: 400, error: "url required" };
6
}
7
8
const host = gatewayHostnameFromUrl(url);
9
if (!host) {
10
return { ok: false, status: 400, error: "invalid url" };
11
}
12
13
const allowed = await collectAllowedGatewayHosts(req);
14
if (!allowed.has(host)) {
15
return {
16
ok: false,
17
status: 403,
18
error: `gateway host not allowed: ${host}`,
19
};
20
}

The permitted set is dynamic. It combines the platform’s sandbox gateway configuration with hostnames derived from APIs belonging to the signed-in seller.

This is more useful than a single global environment variable because it follows the operator’s actual configuration. It also means the trustworthiness of that set depends on how API domains enter and leave the control plane. A hostname set is a policy input, not a magical source of trust.

Once the hostname passes the check, the server performs one outbound request:

1
const upstream = await fetch(url, {
2
method,
3
headers: forwardHeaders,
4
body: hasBody ? input.body : undefined,
5
redirect: "manual",
6
signal: AbortSignal.timeout(timeoutMs),
7
});

Inspect operations use a shorter timeout than paid proxy operations. Both routes use redirect: "manual", so the server does not automatically follow a permitted hostname to a second, unchecked destination.

That redirect policy is an important constraint. It closes one common route by which a valid initial URL can escape its original destination policy.

A constraint, not a complete SSRF guarantee

The hostname check reduces the set of destinations available through the seller server. It should not be described as complete SSRF protection on its own.

A complete outbound-request policy also needs explicit decisions about:

  • accepted URL schemes
  • permitted ports
  • supported HTTP methods
  • private and loopback address resolution
  • outbound request headers
  • request and response limits
  • rate limiting
  • how hostnames become part of the seller’s configured API set

Those decisions are part of the planned gateway hardening work.

This does not make the existing split pointless. It shows why the split matters. Scheme, address and header rules belong inside the gateway boundary. They do not need to complicate every authenticated control-plane request.

The browser also performs some gateway validation before calling the server. That provides faster feedback in the workbench, but the server-side hostname check remains the meaningful boundary. Client validation can improve usability. It cannot authorize an outbound request.

Why separate the routes before hardening them?

Both flows could theoretically live inside one generic proxy handler. That handler would then need to determine which security policy applies to every request.

Is the destination supposed to be fixed or browser-influenced? Should the server inject a seller session or forward protocol-specific headers? Is the path a control-plane operation or a gateway request? Which redirects, methods and timeouts are valid?

A mistake in that branching logic could grant one flow the broader capabilities intended for the other.

Separate routes make the distinction structural:

  • /api/backend/* represents the seller application’s control plane.
  • /api/gateway/inspect represents gateway inspection.
  • /api/gateway/proxy represents paid workbench traffic.

Each route can evolve without inheriting the broadest policy of the others. Authentication response shaping can change without touching URL validation. Gateway method restrictions can change without affecting ordinary dashboard CRUD requests.

That is easier to review, easier to explain and eventually easier to test.

Environment switching is not destination switching

The seller application supports production and development contexts through an ax402_environment cookie and an X-Ax402-Environment header.

Changing the environment does not replace the configured backend origin. The same session proxy still talks to the same control plane. The header tells the backend which logical environment the request belongs to, allowing the interface to separate production data from sandbox workflows.

That environment value is a selector, not an authorization credential. Security decisions still need server-side enforcement.

Some environment-specific workbench restrictions currently live close to the client call sites. The planned hardening pass moves the relevant policy into the gateway routes so that direct requests and interface-driven requests follow the same rules.

Turning the boundaries into executable policy

A boundary becomes trustworthy when its rules are expressed as tests rather than architectural intent alone.

The repository already contains tests around backend configuration, control-plane host derivation and gateway hostname extraction. The next step is route-level coverage for the invariants introduced by this design.

For the session boundary, those invariants include:

  1. The upstream origin always comes from server configuration.
  2. Authentication headers follow one explicit ownership rule.
  3. Authentication responses expose only the data intended for browser code.
  4. Login and logout set and clear the session cookie consistently.
  5. State-changing requests follow the chosen origin or CSRF policy.
  6. Authenticated responses cannot be cached as public content.

For the gateway boundary, they include:

  1. Invalid URLs fail before an outbound request begins.
  2. Destinations outside the permitted hostname set return 403.
  3. Redirect responses are not followed by the server.
  4. Unsupported schemes, ports and methods are rejected deliberately.
  5. Only required x402 headers reach the gateway.
  6. Environment restrictions are enforced in the route handler.
  7. Timeouts abort the upstream request.
  8. Request and response limits fail predictably.

The separation gives those tests somewhere precise to live. Without it, the suite would need to prove that one general-purpose handler never confuses its modes.

Tradeoffs

Separate boundaries add route surface and documentation overhead. Developers need to know which proxy owns which request, and shared forwarding behavior must not drift accidentally.

Cookie-backed sessions also complicate cross-subdomain deployments and local development. They trade the convenience of a JavaScript-managed bearer token for centralized server-side session handling.

The gateway hostname set requires live control-plane data. That keeps it aligned with the seller’s configured APIs, but it also makes availability and domain validation part of the gateway request path.

These are real costs. They are still preferable to hiding two capabilities behind one route whose policy changes according to request shape.

The next hardening pass

The architecture now provides clear places for the remaining work:

  1. Make the session proxy authoritative over authentication headers and response shaping.
  2. Add an explicit origin and CSRF policy for state-changing control-plane requests.
  3. Tighten gateway schemes, ports, methods, address handling and outbound headers.
  4. Move remaining environment restrictions into the server routes.
  5. Add route-level tests for rejection paths, redirects, timeouts and header handling.
  6. Add explicit request and response limits.

These are not unrelated security chores. Each item strengthens one of the two boundaries without broadening the other.

Separate routes do not make proxying safe by default. They make it possible to state exactly what each route may do, test that policy and tighten it without granting the other route more capability.

One boundary forwards authenticated requests to a fixed control plane. The other constrains workbench requests aimed at operator-configured gateways. The current controls will continue to evolve, but the separation is already doing its most important job: keeping two threat models from collapsing into one generic proxy.

That is the decision worth copying.


Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.