A plugin calling your API is easy: same origin, session cookie attached, done. A plugin calling
someone else's API is not — the user's session cookie or your IdP-signed JWT means nothing to
api.acme.com, and putting a vendor API key in a browser bundle means publishing it.
Two patterns work. One is the default.
Pattern A — server-side proxy (recommended)#
Plugin (browser)
│ fetch("/api/proxy/acme/v1/charts", { credentials: "include" })
│ ← same origin, session cookie attached automatically
▼
Your backend
│ validates the session → user U @ tenant T
│ looks up vendor credentials for "acme"
│ calls api.acme.com with vendor-specific auth
▼
Acme backendThe browser never sees a vendor secret, and plugin code stays auth-agnostic. This is the right default
for any cookie-based IdP — Keycloak, Auth0 with httpOnly cookies, anything where the browser can't
read the token.
Plugin side#
@z0devs/mf-plugin-kit ships a thin fetch wrapper with credentials: "include" baked in:
import { createProxyClient } from "@z0devs/mf-plugin-kit";
const acme = createProxyClient("/api/proxy/acme");
type ChartsResponse = { charts: Array<{ id: string; title: string }> };
const data = await acme.get<ChartsResponse>("/v1/charts");
await acme.post("/v1/charts", { title: "New Chart" });
await acme.delete(`/v1/charts/${id}`);You get get / post / put / patch / delete, plus fetch and url for the cases the sugar
doesn't cover. Non-2xx throws with the status preserved.
Backend side#
One route per vendor. Sketch in Fastify:
import type { FastifyInstance } from "fastify";
import { Readable } from "node:stream";
const ACME_BASE = process.env.ACME_API_URL; // https://api.acme.com
const ACME_API_KEY = process.env.ACME_API_KEY; // server-side secret
/** Match the wire format to the content-type actually being forwarded. */
function serializeBody(body: unknown, contentType?: string) {
if (body == null) return undefined;
if (typeof body === "string" || Buffer.isBuffer(body)) return body;
if (contentType?.includes("application/x-www-form-urlencoded")) {
return new URLSearchParams(body as Record<string, string>).toString();
}
// Register a raw/multipart parser and stream those through untouched rather than re-serializing.
return JSON.stringify(body);
}
export async function acmeProxyRoutes(app: FastifyInstance) {
app.all<{ Params: { "*": string } }>(
"/api/proxy/acme/*",
{ preHandler: app.authenticate }, // session cookie → req.user
async (req, reply) => {
const url = `${ACME_BASE}/${req.params["*"]}`;
const contentType = req.headers["content-type"];
const hasBody = !["GET", "HEAD"].includes(req.method);
const response = await fetch(url, {
method: req.method,
headers: {
Authorization: `Bearer ${ACME_API_KEY}`, // vendor-specific auth
"X-Acme-Tenant": req.user.tenantId, // optional tenant scoping
...(hasBody && contentType ? { "Content-Type": contentType } : {}),
},
// Serialize to match the content-type you're forwarding. Blindly `JSON.stringify`-ing mangles
// form-encoded, multipart and text/plain payloads — and contradicts the header above.
body: hasBody ? serializeBody(req.body, contentType) : undefined,
});
reply
.status(response.status)
.type(response.headers.get("content-type") ?? "application/json");
// `response.body` is a WEB ReadableStream; Fastify expects a Node stream, Buffer or string.
return response.body ? Readable.fromWeb(response.body) : null;
},
);
}Production hardening to layer on:
- Allow-list path patterns. Don't blindly forward arbitrary paths — a wildcard proxy is an open redirect for API surface.
- Rate-limit per user and per tenant.
- Audit log every call: method, path, status, user, tenant, latency.
- Strip sensitive request headers from the user's session before forwarding.
- Cache GET responses where the vendor allows it.
Declare it in the manifest#
{
"name": "acme-analytics",
"service": { "id": "acme_analytics", "remoteEntry": "…" },
"vendor": "Acme Corp",
"proxyPrefixes": ["/api/proxy/acme"]
}Metadata, not enforcement
proxyPrefixes exists so your admin UI can show reviewers what a plugin intends to call. The library
does not enforce it. Your backend's path allow-list is the real control.
When Pattern A is the wrong tool#
| Situation | Why the proxy hurts | Better |
|---|---|---|
| The plugin calls your APIs | Same origin already works | Plain fetch with credentials: "include", or the host's contextualApiFetch |
| Latency-critical streams | The proxy adds a hop | Pattern B, direct browser→vendor |
| High-bandwidth uploads | Double-streaming through your backend | Presigned URLs straight to storage |
Pattern B — token exchange#
Only when a vendor wants direct browser→vendor calls and has agreed to validate tokens your IdP mints (a real federation contract, not a phone call):
Mint an audience-scoped token
A backend route — /api/plugin-token?audience=acme.com — performs RFC 8693 token exchange against
Keycloak and returns a short-lived JWT with aud: "acme.com".
Call the vendor directly
The plugin sends it as Authorization: Bearer <jwt> to acme.com/api/….
Vendor verifies
Acme validates the signature via your JWKS endpoint, checks the audience, serves the request.
Not shipped as a library helper — the design depends on the vendor contract, and shipping a generic version would imply guarantees the library can't make.
Host-API calls#
For your own APIs, expose a context-aware fetch through the shared store so plugins never hardcode a base URL or a tenant header:
export const sharedStore = defineSharedStore({
values: { useAuth, contextualApiFetch }, // injects tenant/org context + credentials
registries: { nav: NavRegistry.api },
});const { values } = useShellSharedStore();
const res = await values.contextualApiFetch("/v1/projects");In standalone mode the stub can fall back to plain fetch, so plugin code doesn't branch.