One Door to the Backend: How Our Console's Screens Talk to the Server
An admin console usually starts with a straightforward setup. A page needs data, so it calls an API. Another page needs authentication, so someone adds a token. A third page needs error handling, so its developer copies a helper from somewhere else. It works, and for a while, nobody has a reason to question it.
The problem starts when the console stops being a single application maintained by a single team.
Our operator console brings together several product areas, including devices, content, support and community. Each area owns its screens and ships them independently of the console itself. The console provides the frame that loads those screens, while teams can add, rename or retire them without changing the console's code.
That separation makes development easier, but it introduces a question that becomes harder to ignore as the number of screens grows: how do you let independently developed screens access the backend without letting each one invent its own rules for authentication, security and API access?
A shared API client helps, but it cannot stop a screen from bypassing the client altogether. We wanted the architecture to enforce those boundaries rather than depend on every developer remembering to follow them.
The solution is a small SDK in the browser, a server-side proxy that deliberately does very little, and one rule that shapes the entire design: the browser never receives the operator's actual credential.
The problem: many screens, one backend, no shared boundaries
When a screen communicates directly with the backend, it becomes responsible for more than fetching data. It needs to attach credentials, handle expired sessions, follow the correct API contract and protect requests against cross-site request forgery (CSRF).
It also needs to know which endpoints it is allowed to call.
That last requirement is easy to overlook. A content screen might need to create and edit blog posts, while a support screen might need access to customer and device information. If every screen can make arbitrary requests to the backend, a bug in one area can cross into another. A compromised dependency in a content screen, for example, could attempt to call a refunds endpoint.
A shared API library gives teams a common way to make requests, but it is still a convention. The browser holds the credentials, and nothing inherently prevents screen code from using fetch directly.
We needed a stronger boundary. Screen authors should be able to request the operations their screens require, but they should not have unrestricted access to the network or the credentials used to authenticate those requests.
The approach: a narrow SDK and a deliberately simple proxy
The architecture separates the work between three components.
Each screen receives an SDK object when the console mounts it. The SDK exposes a limited set of capabilities: making API requests, checking permissions for the UI, navigating between screens, opening records and displaying messages. A screen doesn't receive the operator's token or construct backend URLs itself.
Every API request then passes through a backend-for-frontend (BFF) proxy hosted on the console's server. The proxy validates the request, resolves the operator's server-side session, checks that the request belongs to the calling screen's permitted area, and forwards it to the backend using the operator's actual credential.
The backend remains responsible for authorization. The proxy establishes that a request is valid for the console's access path and stays within the screen's assigned boundary; it does not decide whether the operator is allowed to perform the underlying business operation.
That distinction is important. The proxy restricts where a screen can send a request, while the backend decides what the signed-in operator can actually do.
What one request looks like
A content screen might call the SDK like this:
const post = await sdk.api.post("/posts", {
title,
body,
});The screen only knows about /posts. It doesn't know the backend's hostname, the operator's credential or the mechanics of forwarding the request.
The SDK is created for that particular screen and pins API calls to its assigned area. A content screen can call its own content endpoints, but it cannot use the same SDK to construct a request to the payments area.
The request travels through the following path:

The screen's request goes to the console server first. Only the server has access to the operator's real credential, and only the backend makes the final authorization decision.
The proxy checks the request, then gets out of the way
The proxy is intentionally narrow in scope. It is not another business-logic layer, and it should not gradually become one.
First, it resolves the session associated with the incoming request. If the session is missing or invalid, the request fails with 401 Unauthorized.
Next, it checks CSRF protection. Requests that modify data must include the expected custom header and an Origin matching the console's own origin. A malicious website should not be able to forge an accepted cross-site request simply by causing a user's browser to send one.
The proxy then checks the requested path against the calling screen's assigned area. It rejects paths that escape that area, including attempts to use path traversal sequences such as ...
Before forwarding the request, it removes browser-supplied identity and credential headers. Only an explicit allowlist of headers is forwarded. In particular, the browser cannot choose the Authorization credential that the backend receives; the proxy attaches the credential associated with the validated operator session.
Finally, the proxy streams the backend response back to the browser, forwarding only the response headers the console needs.
Streaming matters because not every request returns a completed response immediately. Server-sent events (SSE), for example, keep an HTTP connection open to deliver updates as they occur. A proxy that buffers the entire response would interfere with that behavior, so the hosting environment must support streaming end to end.
The goal is to keep this layer predictable: validate the request, enforce the boundary, attach the correct identity and forward the response.
Why the operator's credential never reaches the browser
One alternative would be to use a shared service credential for every request. It would simplify authentication, but it would also create a single, highly privileged secret whose compromise could affect the entire console.
Instead, the backend receives the signed-in operator's own credential. Its authorization checks and audit logs therefore continue to identify the actual person performing an operation.
The browser gets something different: an opaque session cookie containing a random session identifier, with no access token embedded in it.
The cookie is configured with HttpOnly, so page JavaScript cannot read it; Secure, so it is sent only over HTTPS; and SameSite=Lax, which limits when it accompanies cross-site requests. The __Host- prefix adds browser-enforced restrictions on the cookie's domain and path attributes.
The actual tokens are kept in a server-side session store, encrypted and subject to idle and absolute expiration limits. The server refreshes tokens when necessary, and signing out invalidates the stored session.
This changes what an attacker can obtain from a compromised screen. Malicious JavaScript may still be able to make requests using the victim's active browser session, depending on the surrounding protections, but it cannot simply read the operator's access token and take it elsewhere.
That distinction matters: keeping credentials out of the browser reduces the consequences of credential theft, but it does not make a compromised page harmless. The proxy boundaries, CSRF protection and backend authorization checks still have to hold.
The screens themselves need a trust boundary too
Restricting API access is only part of the problem. The console also loads screen code at runtime, and that code executes in the browser.
A screen therefore needs its own integrity and loading controls.
Each screen file is pinned by a cryptographic hash using Subresource Integrity (SRI). If the downloaded file does not match the expected hash, the browser refuses to execute it. Shared dependencies such as React are resolved through a common import map so that independently shipped screens use the intended shared library versions.
A Content Security Policy (CSP) further restricts where the page can connect. In particular, network access is limited to the console's own origin, forcing screen requests through the proxy rather than allowing arbitrary direct calls to external backend services.
These controls serve different purposes. SRI checks whether a file matches the expected content. The import map standardizes dependency resolution. CSP restricts where code can communicate. Together, they make it harder for an independently loaded screen to bypass the boundaries the console relies on.
They are not substitutes for one another, and none removes the need to review and control which screen code is trusted in the first place.
The console doesn't need to know every feature
The same separation applies to the console's navigation and feature registry.
Instead of hardcoding every product area into the console, the server provides a registry for the signed-in operator. It describes the areas and pages that operator can access, the relevant permissions and the files associated with each screen.
The console uses that registry to build its menu and routing behavior. If an operator cannot access a feature, the console can display a generic unavailable state rather than exposing details about a feature they do not have permission to use.
This also makes the system easier to extend. Adding a product area does not necessarily require a console release; the backend can update the registry to describe the new screens and their access rules.
The registry also supports navigation across product areas without making each screen depend on the internal structure of every other area.
For example, a support screen might need to open a device record. It can express the action through the SDK:
sdk.open({
action: "view",
type: "device",
id,
});The console resolves the action using the operator's registry. The support screen does not need to know which module owns the device page or how that page is implemented.
If no accessible screen handles the action, the console shows an unavailable state. The navigation mechanism remains independent of the feature that ultimately handles the request.
When a screen fails, the rest should keep working
Independent screen delivery introduces another failure mode: a screen may fail to load, crash during mounting or require an API contract that the current console cannot support.
Those failures should remain isolated to the affected screen. The console can display a local error state with a Retry action and report the failure to the backend, while other product areas continue to operate.
Contract mismatches need similarly deliberate handling. If a screen is incompatible with the version of the SDK or platform contract available in the console, the system should identify that incompatibility rather than allowing the screen to fail unpredictably. Where the problem can be resolved by loading the current screen version, the SDK can trigger a reload; otherwise, the console should surface a clear error.
The point of independently shipping screens is that one team's release should not automatically become an outage for every other team. That benefit only holds if loading failures, runtime exceptions and compatibility problems are contained at the screen boundary.
What this design costs
The architecture adds an extra network hop to every API call. Requests now pass through the console server before reaching the backend, adding latency and another service that must be deployed, monitored and scaled.
It also creates a central dependency. If the console server or session store becomes unavailable, the screens may lose backend access even when the backend itself is healthy.
Session management introduces additional load because requests need to resolve the operator's session, and extending idle expiration may require updates to the session store. As usage grows, that work needs to be measured and managed.
Header allowlists create a maintenance responsibility as well. A backend endpoint might introduce a response header for a download filename or a retry delay, but the browser will not receive it unless the proxy explicitly permits it.
Idempotency also requires care. A fresh key for every HTTP request does not protect a user action from being submitted twice. If the SDK is expected to deduplicate retries or double-clicks, it must define how a key is generated and retained for the lifetime of the logical operation.
Finally, the SDK limits what screen authors can do directly. A screen cannot freely combine data from several product areas in the browser. If a feature needs information from multiple areas, it may require a dedicated backend endpoint instead.
That is a real trade-off. The design exchanges some flexibility and an additional hop for clearer security boundaries, consistent session handling and more predictable behavior across independently developed screens.
For an internal console that handles customer and operational data, that trade-off is deliberate. A latency-sensitive public application with different trust requirements might reasonably make a different choice.
What matters most
The central decision is to make the safe path the default path.
Screens receive a small set of capabilities rather than unrestricted network access. Credentials remain on the server instead of being exposed to browser JavaScript. The proxy enforces request boundaries without taking over the backend's authorization responsibilities, and the backend continues to identify the actual operator behind every action.
The registry keeps the console independent of individual product areas, while runtime isolation allows teams to ship and maintain their screens without making every release a console-wide change.
None of this removes the operational cost of a proxy or the need to secure dynamically loaded code. It does, however, give the system a clear place to enforce the rules that every screen must follow.
As the number of screens and contributing teams grows, that consistency becomes increasingly important. Security should not depend on whether a developer remembered to attach the right token, call the correct helper or avoid an endpoint outside their feature area.
The architecture should make those mistakes difficult to express in the first place.