ehsan.blog
~/blog/api-client-part-15-rpc-bridge-security-model — zsh
cat api-client-part-15-rpc-bridge-security-model.md

The SSR RPC bridge, part 2: the security model, threat by threat

·6 min read

Last post covered the architecture and one deliberate design choice — deny-by-default. This post is the rest of the threat model, because “we have an allowlist” is necessary but nowhere near sufficient for something that takes arbitrary { module, method, args } payloads from the browser and calls real server-side code with them.

Threat 1 — calling something not on the allowlist

Covered last post, but worth restating as the baseline: any module/method combination not explicitly listed in expose returns a 403 before touching your real client at all. This is checked first, before anything else in this post runs.

plaintext
POST /api/rpc
{ "module": "admin", "method": "deleteAllUsers", "args": [] }

→ 403, rejected. "admin" was never added to expose.

Threat 2 — prototype pollution via crafted args

Problem: the request body is arbitrary JSON from an untrusted client. JSON like {"__proto__": {"isAdmin": true}}, naively merged or spread into an object your server code uses, can pollute Object.prototype for the entire Node process — not just one request’s data, every object in that process, for every user, until it restarts. This is a real, well-documented class of vulnerability, not a theoretical one, and it’s exactly the kind of thing that’s invisible until someone actively probes for it.

Solution: incoming args are deserialized through a path that explicitly strips __proto__, constructor, and prototype keys before anything touches them, and objects are built with Object.create(null) rather than plain object literals where the deserializer constructs data from user-controlled JSON. This isn’t optional or configurable — it’s not a setting you could accidentally disable, because “user input can reach Object.prototype” isn’t a tradeoff worth exposing as a knob.

Threat 3 — CSRF, per transport

Problem: the RPC endpoint is a same-origin POST, which sounds safe until you remember that “same-origin” doesn’t mean “only your frontend can trigger it” — a malicious page on a different origin can still cause a logged-in user’s browser to fire a request to your same-origin endpoint, riding on their existing session cookie. That’s CSRF, and it applies here exactly as much as it applies to any cookie-authenticated POST endpoint.

Solution: the bridge handles this differently depending on your auth transport, because the right defense actually depends on it:

  • Cookie-based auth — the classic CSRF case. The bridge issues and validates a CSRF token, checked on every RPC call, following the standard double-submit or synchronizer pattern.
  • Bearer-token auth (token in a header, not a cookie) — inherently much less exposed to CSRF, since a cross-origin page can’t read or attach your token to a forged request the way it can silently ride along on a cookie. The bridge doesn’t force CSRF token overhead onto this case, since it’d be solving a problem that transport doesn’t actually have.

Getting this wrong in either direction is a real failure mode: skipping CSRF protection for cookie auth is a genuine hole, and forcing CSRF tokens onto a bearer-token setup that doesn’t need them is just friction with no security benefit. The bridge picks the right one based on your configured auth strategy from the auth post, not a one-size-fits-all default.

Threat 4 — args that don’t match what the method expects

Problem: the allowlist says products.getProductById is callable, but it doesn’t by itself say anything about what a valid call to it looks like. A malicious or just malformed payload could send the wrong number of args, wrong types, or an absurdly large payload as a denial-of-service attempt against your server’s own resources.

Solution: this is where it connects back to the runtime validation from a few posts ago — if you have request/response schema validation configured via your OpenAPI spec, the RPC bridge uses that same validation on incoming args before they ever reach your real method implementation. A malformed call fails validation and gets rejected, same shape of defense as the response validation from post twelve, just applied to the inbound side of an RPC call instead of the outbound side of a normal request.

What the bridge does not do — and whose job it actually is

I want to be as direct about this as the last post was: the bridge is not a replacement for your own authentication and authorization logic. It controls reachability — which methods can be called at all from the browser. It does not know, on its own, whether this specific user is allowed to call products.getProductById for this specific product ID, or whether they should see the full response or a filtered version of it.

plaintext
RPC bridge's job:
  "Is 'products.getProductById' on the allowlist at all?"  → yes/no

Your auth code's job:
  "Is THIS user allowed to see THIS product?"                → your logic
  "Should sensitive fields be stripped from the response?"    → your logic

Both checks matter, and they’re not redundant — the bridge’s check happens first and is coarse (method-level), your own auth logic happens after and is fine-grained (user-and-data-level). A common mistake I’d genuinely flag if you’re implementing this: treating “it’s on the allowlist” as “therefore it’s safe to expose unconditionally,” and skipping the per-request auth check inside the actual method implementation because the allowlist already felt like enough of a gate. It isn’t — it was never meant to be.

Real talk: this took longer to get right than the rest of the package combined

Every other feature in this series — caching, retries, even the OAuth2 refresh coalescing — has a fairly clear “correct” implementation once you understand the problem. The RPC bridge’s security model doesn’t have that luxury; getting the CSRF-per-transport logic right, getting the prototype pollution stripping actually comprehensive instead of just handling the obvious __proto__ case, took real iteration and, honestly, reading a lot of other people’s postmortems about similar bridges done wrong. If you’re evaluating whether to trust this in production, this is the part of the package I’d want scrutinized the hardest — and I mean that as an invitation, not a disclaimer.

What sticks from this post

  • Deny-by-default reachability is the first layer, not the only one.
  • Prototype pollution defenses aren’t configurable — user-controlled JSON can never reach Object.prototype through this path.
  • CSRF handling adapts to your auth transport: real token-based protection for cookie auth, skipped for bearer-token auth where it wouldn’t help.
  • Incoming RPC args get the same schema validation your outgoing responses do, catching malformed or malicious payloads before they reach your code.
  • The bridge controls what’s reachable; your own auth/authz still controls who can reach it and what they see — never treat allowlisting as a substitute for a real per-request auth check.

Next: framework guides — Next.js (both RSC and client components), Vite SPAs, edge runtimes, and plain Node scripts, with the specific setup for each.

  • developerehsan
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.