Skip to main content
Mark Basford
Architecting for Everything — Part 6

The Server-Side Shield

Mark Basford10 min

In the 1940s, American banks faced a problem: how do you let customers transact without letting them anywhere near the vault? Their solution was the pneumatic tube - a pressurised cylinder that whooshed capsules between the customer window and the teller's station. The interaction felt almost playful. What customers didn't see: reinforced tubing, one-way valves, the physical impossibility of reaching through to the other side. The friendly whoosh masked a deliberate architectural boundary.

The technology spread to department stores, hospitals, and eventually some British banks in the 1960s. Most have been replaced now. But the principle endures.

We found ourselves thinking about pneumatic tubes when designing how our frontend talks to the backend.

The Invisible Attack Surface

In mid-2024, security researchers reverse-engineering the Rabbit R1 AI device discovered something that should have been impossible to find: API keys hardcoded directly into the client-side code. Not just any keys - the ElevenLabs key had full administrative privileges. With that single exposed credential, attackers could access the complete history of every text-to-speech message ever generated by any R1 device.

The keys had been sitting there, in code that shipped to every customer, for months.

This wasn't a sophisticated attack. Someone looked at the client bundle and found secrets that should never have left the server. The Rabbit team presumably knew better. Most developers know better. And yet IBM's research shows that breaches involving stolen credentials take an average of 292 days to detect - longer than any other attack vector. The secrets leak quietly. The damage accumulates invisibly.

We didn't want to learn this lesson firsthand.

The Principle

The architecture is simple to state: the browser never talks directly to the backend API. Every request goes through the server.

Browser → Next.js Server → Backend API

Not browser to backend. Not client-side fetch calls with API keys tucked into environment variables. The browser talks to our Next.js server, which talks to the backend. The browser never sees the backend URL, never handles authentication tokens directly, never has access to secrets that could be extracted from a bundle.

The pneumatic tube, not the open window.

Server Actions as the Boundary

Next.js Server Actions give us a clean way to enforce this boundary. Any function marked with 'use server' runs exclusively on the Node.js server - it physically cannot execute in the browser.

'use server';

export async function authenticate(credentials: Credentials) {
  const http = createSecureClient();
  const response = await http.post('/auth/login', credentials);
  return response.data;
}

That 'use server' directive isn't a suggestion. The bundler strips these functions from client code entirely. They exist only on the server. The credentials go in, the response comes out, but the actual HTTP call to the backend happens in a context the browser cannot inspect.

Every authentication function in our codebase follows this pattern. Login, signup, OTP validation, session management - all server actions. The browser submits form data; the server handles the sensitive operations.

CSRF Protection

Server-side API calls solve the secret exposure problem, but they don't prevent cross-site request forgery. If a malicious site can trick a user's browser into submitting a form to our server, the server would dutifully forward that request to the backend.

We use Laravel Sanctum's CSRF protection, integrated through axios interceptors:

'use server';

export function createSecureClient() {
  const cookieStore = cookies();

  const client = axios.create({
    withCredentials: true,
    withXSRFToken: true,
  });

  client.interceptors.request.use((config) => {
    const token = cookieStore.get('XSRF-TOKEN')?.value;
    if (token) {
      config.headers['X-XSRF-TOKEN'] = decodeURIComponent(token);
    }
    return config;
  });

  return client;
}

The flow: when the application loads, we fetch a CSRF token from the backend. That token lives in a cookie. Every subsequent request includes the token in a header. The backend validates that the header matches the cookie - something a cross-site attacker cannot forge because they can't read our cookies.

The axios instance itself is created server-side. The interceptor runs server-side. The cookie reading happens server-side. The browser never touches the CSRF machinery directly.

Environment Isolation

Here's where the pneumatic tube metaphor becomes concrete. Consider a typical environment configuration:

export const config = {
  // Server-side only - never exposed to browser
  signingSecret: process.env.SIGNING_SECRET,
  apiEndpoint: process.env.API_ENDPOINT,
  cookieName: process.env.COOKIE_NAME,

  // Public - explicitly prefixed, safe to expose
  appTitle: process.env.NEXT_PUBLIC_APP_TITLE,
};

The NEXT_PUBLIC_ prefix is the boundary. Variables without that prefix exist only in the server runtime. They're not bundled, not shipped to browsers, not inspectable in DevTools. The signing secret, the API endpoint, the cookie name - none of it leaves the server.

If someone decompiles our client bundle, they'll find nothing useful. The secrets are on the other side of the tube.

JWT Session Management

When a user authenticates successfully, we generate a session token. This happens entirely server-side:

'use server';

export async function createSession(
  user: UserData,
  expiresAt: Date
): Promise<void> {
  const cookieStore = cookies();

  const token = await new SignJWT({ user })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(Math.floor(expiresAt.getTime() / 1000))
    .sign(new TextEncoder().encode(config.signingSecret));

  cookieStore.set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
  });
}

The JWT is signed with a secret the browser never sees. It's stored in an httpOnly cookie the browser cannot read via JavaScript. The sameSite: 'lax' flag prevents the cookie from being sent on cross-site requests. The secure flag ensures HTTPS-only transmission in production.

Four layers of protection, none of which require the browser to be trusted.

Events as a Security Layer

There's one more boundary worth mentioning. Our forms don't call server actions directly - they publish events.

// Client component publishes intent
const handleSubmit = async (values) => {
  await events.publish('auth.login.requested', values);
};

// Server-side handler subscribes and executes
events.subscribe('auth.login.requested', async (data) => {
  const result = await authenticate(data);
  await events.publish('auth.login.completed', result);
});

The client expresses intent. The server decides what to do with it. The client never knows the API structure, the endpoint URLs, or the response handling logic. It publishes an event and waits for a result.

This decoupling has security benefits beyond obscurity. Every event is validated against a schema. Every event is logged for audit trails. The event system becomes a checkpoint where we can add rate limiting, validation, or security monitoring without touching the UI code.

We'll explore the event architecture more deeply in a future post. For now, the relevant point is that it adds another layer to the shield.

What We Prevent

Threat Mitigation
Secret exposure in bundles Server-side only, no NEXT_PUBLIC_ prefix
CSRF attacks XSRF-TOKEN + SameSite cookies
XSS token theft HttpOnly cookies, server-side JWT
API structure exposure Event-driven indirection
Session hijacking HTTPS-only, short TTL, secure cookies
Credential interception Server-to-server communication

None of these mitigations are novel. They're well-documented security practices. The architecture just ensures they're applied consistently, by default, without requiring every developer to remember the rules.

The Tube, Not the Window

The pneumatic tube worked because you physically cannot reach through it. The capsule goes in, the capsule comes out, but the mechanism - the pressure, the valves, the routing - happens in a space you cannot access.

Our server-side architecture works the same way. The browser sends requests, receives responses, but never touches the machinery that processes them. The secrets stay on the server. The API calls happen server-to-server. The authentication tokens live in cookies the browser cannot read.

It's not clever. It's not innovative. It's just a boundary, consistently enforced, that prevents an entire category of vulnerabilities from being possible in the first place.

Sometimes the best security is the attack surface that doesn't exist.


Technical Notes

Patterns Used:

  • Next.js Server Actions ('use server' directive)
  • CSRF protection via Sanctum integration
  • JWT signing with jose library
  • HttpOnly + SameSite + Secure cookies
  • Event-driven form handling

Related Posts:

  • FR-0001: The Multizone Gambit
  • FR-0014: Seven Ways to Deliver an Event (coming soon)

References:


A Note on How This Was Written

As with all posts in this series, this was written with AI assistance. The architectural decisions and security patterns are real implementations in our codebase. The Rabbit R1 incident informed our thinking before we wrote a line of authentication code - learning from others' mistakes being preferable to making our own. Every code example is representative of the patterns we use.