The SSR RPC bridge, part 2: the security model, threat by threat
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.
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.
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 logicBoth 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.prototypethrough 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