Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

Plugin API auth

How a plugin calls a non-host API — the server-side proxy pattern, its library helper, and when token exchange is the better fit.

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.

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 backend

The 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:

src/routes/proxy/acme.ts
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#

SituationWhy the proxy hurtsBetter
The plugin calls your APIsSame origin already worksPlain fetch with credentials: "include", or the host's contextualApiFetch
Latency-critical streamsThe proxy adds a hopPattern B, direct browser→vendor
High-bandwidth uploadsDouble-streaming through your backendPresigned 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.

Next#