DEV Community

Cover image for Your Mini-Apps Are Calling APIs You Never Meant to Expose — Designing the Host Communication Boundary
FinClip Super-App
FinClip Super-App

Posted on

Your Mini-Apps Are Calling APIs You Never Meant to Expose — Designing the Host Communication Boundary

The sandbox keeps mini-apps from escaping. The bridge keeps them from reaching things they shouldn't ask for. Most teams build the first and forget the second.

If your super app has more than a handful of mini-apps, here's a test. Pick any mini-app — especially one a partner built — and answer this without reading the code: exactly which host APIs can it call, what data can it read, and what device capabilities can it invoke?

If answering requires reading the source, the boundary was never designed. It just happened. Let's fix that.

How the problem starts

Early in a super app's life, communication between host and mini-app is informal and direct:

// Mini-app calls whatever the host exposes — no gate, no contract
const token = host.auth.getToken();
const contacts = host.device.readContacts();
const internal = host.services.orderEngine.getState(); // never meant to be public
Enter fullscreen mode Exit fullscreen mode

This works at three mini-apps, one team, all trusted. The moment you onboard an outside partner, every line above becomes a liability: the partner can read auth tokens, access device contacts, and hit an internal service nobody intended to expose. There's no boundary — there's just "whatever the code can reach."

The fix: one bridge, no side channels

All communication — every question a mini-app asks the host — passes through a single controlled bridge. Nothing else:

// Direct access — mini-app reaches into host internals
const state = host.services.orderEngine.getState();

// Bridge — mini-app requests a capability; bridge decides
const profile = await bridge.invoke("user:readProfile");
// bridge checks: is this mini-app granted "user:readProfile"? -> allow/deny
Enter fullscreen mode Exit fullscreen mode

The bridge is not an abstraction. It's a policy enforcement point. Here's what it does:

class HostBridge {
  constructor(miniAppId, grantedCapabilities) {
    this.appId = miniAppId;
    this.granted = new Set(grantedCapabilities);
  }

  async invoke(capability, params) {
    // 1. Is this capability granted to this mini-app?
    if (!this.granted.has(capability)) {
      this.audit("DENIED", capability);
      throw new PermissionDeniedError(this.appId, capability);
    }

    // 2. Rate limit — per mini-app, per capability
    if (this.rateLimiter.isExceeded(this.appId, capability)) {
      this.audit("THROTTLED", capability);
      throw new RateLimitError(this.appId, capability);
    }

    // 3. Execute against the internal handler (mini-app never sees this)
    const result = await this.handlers.get(capability).execute(params);

    // 4. Log everything — who, what, when, result
    this.audit("ALLOWED", capability, params, result);

    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

Four things happened in that code that cannot happen with direct access: permission check, rate limit, controlled execution, and audit log. The mini-app never called a host function — it requested a capability, and the bridge decided.

Capability grants live in the platform, not the mini-app

A critical design point: the grant lives in platform policy, not in the mini-app's own manifest. If the mini-app declares its own permissions, a developer can quietly widen them:

{
  "appId": "partner_checkout",
  "capabilities": {
    "granted": ["user:readProfile", "payment:createOrder"],
    "denied_by_default": true
  },
  "network": {
    "default": "deny",
    "allow": ["api.partner-domain.com"]
  }
}
Enter fullscreen mode Exit fullscreen mode

denied_by_default: true is the whole philosophy. A mini-app starts with access to nothing. Every capability is opt-in, explicitly granted by the platform. The mini-app cannot grant itself capabilities, cannot discover what else exists, and cannot reach any host API the bridge doesn't surface.

The host's internals stay invisible

Without a bridge, every internal host API is technically reachable by mini-app code — which means every internal API is part of the security surface whether you meant it or not. Changing an internal service becomes risky because a mini-app might depend on it.

With a bridge, the internal surface disappears:

// What the mini-app sees (the bridge surface):
bridge.invoke("user:readProfile")        // exposed
bridge.invoke("payment:createOrder")     // exposed

// What the mini-app CANNOT see (host internals):
host.services.orderEngine               // unreachable — not on the bridge
host.auth.tokenStore                    // unreachable
host.device.rawFileSystem               // unreachable
host.config.internalFlags               // unreachable
Enter fullscreen mode Exit fullscreen mode

The bridge is a decoupling layer. Host internals can change freely behind it — refactor the order engine, restructure auth, rename everything — and no mini-app breaks, because no mini-app ever saw those internals.

Preventing cross-mini-app leakage

One more failure mode: without a designed boundary, two mini-apps can sometimes observe each other through shared state:

// Two mini-apps sharing a global event bus — each sees the other's events
globalBus.on("order:created", handler);   // partner A listens
globalBus.emit("order:created", data);    // partner B emits — A reads it

// Bridge isolates communication per mini-app
// Each mini-app gets its own scoped channel — no shared bus
const channel = bridge.scopedChannel(miniAppId);
channel.on("order:created", handler);     // only sees its own events
Enter fullscreen mode Exit fullscreen mode

Cross-app isolation isn't just a sandbox property (can't read each other's memory). It's a communication property: can't observe each other's messages, events, or state through shared channels.

Putting it together

Single bridge, capability grants in platform policy, default-deny, rate limiting, audit logging, host internals unreachable, per-app scoped channels. Here's the shape:

This is a build-vs-buy point because the bridge has to exist before the first partner mini-app ships. Retrofitting a communication boundary after partners are already calling undocumented host APIs is one of the most painful migrations in platform engineering. A platform built for this ships it as foundation — FinClip, for example, routes all host-to-mini-app communication through a controlled API bridge with capability-based access, so the boundary is an architectural fact from day one.

The test

  1. Can a mini-app call any host function that isn't on the bridge? (It must not be able to.)
  2. Can a mini-app discover or invoke capabilities it wasn't explicitly granted? (Fail closed.)
  3. Can two mini-apps observe each other's events or state through a shared channel? (They shouldn't.)
  4. Can you produce a per-call audit log showing what each mini-app requested and whether it was allowed? (You'll need to.)

A "no" on #1 means you don't have a boundary — you have a suggestion. Which of these is hardest to answer "yes" to? 👇


More on super app architecture, communication design, and runtime governance → https://super-apps.ai/

Top comments (0)