Security posture
Meridian's security model today rests on three deliberate choices rather than one unified subsystem: the platform delegates end-user authentication to whatever sits in front of it, it refuses to let secrets enter version control by construction, and it treats plugins as trusted code rather than sandboxed extensions. None of this is accidental gap-filling — each choice trades a capability the platform could build for one the surrounding deployment (hospital network, reverse proxy, IAM) almost certainly already has. This page explains that reasoning and its consequences. For the mechanics of actually wiring ports, secrets, or plugins, follow the how-to links inline.
No built-in authentication
The proxy — the platform's one HTTP surface for events, runs, records, the
catalog, and spec persistence — has no authentication layer today. It is a
plain Node http server (apps/api/src/server/api.ts) with no session, token,
or API-key check anywhere in the request path, and no CORS policy of its own.
The console (ui) is a stateless Next.js app with the same absence of
built-in login. This is not a bug that slipped through review; the platform
simply has not built this layer yet, and the roadmap
tracks it as future work.
Why the reverse-proxy model, for now
Meridian is designed to run inside a hospital's or integrator's internal network, where an authenticating reverse proxy (OIDC, mTLS, or both) is usually already the entry point for every internal tool — the hospital's own identity provider, session model, and audit trail. Building a bespoke authentication layer into the platform would duplicate that infrastructure, and duplicated auth is a classic source of divergence: two places that can disagree about who is logged in, two places to patch when a vulnerability surfaces. Deferring to the perimeter is the more conservative choice while the platform doesn't yet have a first-party answer, rather than shipping a partial one that creates a false sense of coverage.
The consequence is operational, not optional: never expose the proxy or the UI directly to the internet. Put both behind a reverse proxy that authenticates the caller, on an internal network, and terminate TLS there — the stack talks plain HTTP internally, which is only safe inside that perimeter.
What must stay unpublished
Some ports are not meant to be reached by end users at all, only by the stack's own components:
- Restate's ingress (
8080) and admin API (9070) — the control plane that every event and run passes through. - The endpoint (
9080) — the engine process itself; the proxy never calls it directly, only through the Restate ingress (RESTATE_URL).
None of these three should be published on a network reachable by anything other than the other stack components. The full port and process inventory, including which ones compose actually publishes versus keeps internal, is in Topology; adapter-specific exposure (e.g. a FHIR warehouse's own auth) is covered in Connect a FHIR warehouse and Configure ports and adapters.
Secrets never enter version control
Instance manifests are meant to live in git — that's what makes a deployment's
port wiring, plugin list, and terminology routing reviewable and diffable over
time. Which is exactly why they must never carry a secret: a credential
committed once stays in history forever, reviewable or not. The manifest
schema (meridian/instance-v1) has no secret fields at all — adapter config
blocks hold things like a FHIR baseUrl or a webhook URL, never a bearer
token or a key.
The manifest/environment split
Anything that is sensitive — authenticated FHIR access, LLM provider keys,
registry credentials — is read from the process environment instead, never
from the manifest. apps/api/src/env.ts loads a repo-root .env file for
convenience in a checkout, but an explicit shell export always wins over a
value from that file, and in a container or Kubernetes deployment you inject
variables through the service environment (compose env_file/environment,
a Kubernetes Secret) rather than shipping a .env at all. See
Environment variables for the full
list of what each process reads, and
Configure ports and adapters for
why an adapter's auth header belongs in the environment rather than in
config.
This split is deliberate rather than incidental: it keeps the manifest as a single, reviewable statement of what talks to what, while secrets live wherever your platform's own secret story already is. Meridian does not invent its own secret store or vault integration — it reads environment variables, which every deployment target (compose, Kubernetes, a cloud secret manager projected into env) already knows how to inject.
Where credentials actually resolve
Two integrations lean on Google's credential chain rather than a raw key baked in anywhere:
- The plugin registry, when backed by GCS: the store requests an access
token through
google-auth-library'sGoogleAuthclient (libs/plugin-cli/src/registry/store.ts), which transparently resolves Application Default Credentials — a mounted service-account key file, or workload identity federation with no key file on disk at all. The IAM role granted to that identity (roles/storage.objectViewerfor the instance reading plugins,roles/storage.objectCreatorfor authors publishing them) is what actually bounds access, not the scope the client happens to request. See Set up a plugin registry. - The Posos plugin's IAP-authenticated calls resolve the same way through
GOOGLE_APPLICATION_CREDENTIALS(external-plugins/posos/lib/client.ts), again honoring ADC/workload identity rather than requiring a literal key in an environment variable.
Neither of these is Meridian-specific machinery — they are the standard Google credential resolution order, which is exactly the point: the platform doesn't re-invent credential handling, it defers to what the surrounding cloud identity already provides.
Plugins are trusted code, not sandboxed extensions
A plugin contributes behaviors, adapters, and agents as plain JavaScript
modules, and the host loads them with a dynamic import()
(apps/api/src/plugins.ts, applyManifest) — the same as any other module in
the process. There is no VM, no worker isolation, no capability restriction:
a loaded plugin runs with the full privileges of the engine process, including
whatever network access, filesystem access, and environment variables that
process has. "Install a plugin" and "give it the same trust level as
first-party code" are the same action today.
The SDK-range check is a compatibility gate, not a security gate
At boot, each plugin's sdk: range is checked against the running
PLUGIN_CONTRACT_VERSION with the npm semver package's satisfies
(apps/api/src/plugins.ts). A mismatch is a soft failure — the plugin is
logged and skipped, the rest of the host still boots — exactly like a
peerDependencies check in any Node package. It tells you a plugin is
compatible with this SDK's shape; it says nothing about whether the plugin's
code is trustworthy. Don't read a clean SDK check as a security signal.
Registry immutability and IAM bound who publishes, not what
The registry (libs/plugin-cli/src/registry/registry.ts, store.ts) enforces
that a published version is immutable — a put uses ifGenerationMatch: 0
(GCS) or an equivalent create-if-absent check (directory backend), so
republishing an existing version is refused outright, you bump instead. That
guarantees a version doesn't change under you after you've declared it. It
does not verify a checksum or a signature of the artifact itself: nothing in
the download path (apps/api/src/registry.ts, ensurePlugin) checks that a
downloaded dist/ is byte-for-byte what a particular author intended, only
that whatever is there won't silently change later. Today's actual control is
therefore entirely procedural — grant roles/storage.objectCreator (or
write access to a directory registry) only to authors you trust, and only
declare plugins from a registry you control — not cryptographic.
This is a known, tracked gap rather than an oversight. The packaging plan
already earmarks a signed-bundle distribution channel (OCI-based, with
capabilities) as the enterprise-grade target, and calls out the plugin
loader's dynamic import() as precisely the seam where a sandboxed
execution tier (WASM or an isolated worker) would later replace trusting the
loaded JavaScript outright — without changing how a plugin is declared or
discovered. See
Plugin host loading,
Bundling and distribution,
Autonomy and packaging,
and the ADR on packaging and SDK externalization
for where that trajectory is heading. Until it lands, Declare and install
plugins and
Set up a plugin registry are the
levers you actually have: control who can publish, and control which registry
an instance is pointed at.
What never reaches the logs
Structured logs (pino, apps/api/src/observability/logger.ts) are shaped
around one hard rule: clinical events are logged only by type and id, never
by payload — a patient's data simply never becomes a log argument in the
first place, which is a stronger guarantee than any redaction rule could give
after the fact. On top of that, a redaction list censors the usual secret
field names (authorization, apiKey, api_key, password, token,
req.headers.authorization, config.apiKey) as defense in depth, in case one
ever ended up in a log call by mistake. Treat the redaction list as a
backstop, not the primary control — the primary control is that patient
payloads and secrets aren't passed to logger.* calls to begin with. See
Enable observability and
Observability signals for what does
get logged and traced.
What's deliberately deferred, and why
Pulling the threads above together, three things are consciously not built yet, each with a stated reason rather than silence:
- Built-in authentication — deferred to the reverse-proxy/gateway that a deployment almost always already runs, to avoid duplicating an identity system the surrounding network already provides.
- Plugin signing / provenance verification — deferred behind a procedural control (registry IAM, immutability) while the signed-OCI distribution channel is designed; today's guarantee is "the version you declared won't change under you," not "this code was written by who it claims to be."
- Plugin execution sandboxing — deferred behind the fact that a plugin
author today is effectively a co-maintainer of the engine process; the
packaging plan's dynamic-
import()seam is where a WASM/worker tier would attach later without changing the plugin contract.
Each of these is a trade against building the platform's own version of something the operator's environment can often already supply, made visible here rather than left implicit. Revisit this page against the roadmap before treating any of the three as permanently out of scope.
Where to go next
- Topology — the exact port/process inventory this page's "keep unpublished" guidance refers to.
- Environment variables — every variable that carries a credential, per process.
- Instance manifest — the schema that deliberately has no secret fields.
- Set up a plugin registry and Declare and install plugins — the IAM and provenance controls available today.
- Enable observability — logging and tracing configuration, including the redaction list.
- Bundling and distribution and Autonomy and packaging — where plugin trust is headed.
- Why durable execution — the three-plane split this posture assumes (execution plane holds no authoritative state of its own).
- Glossary — terms used throughout.